merge: phases 1–5 refactor, CI hardening, docs (coolify → main)
Some checks failed
Build + Deploy / build-admin-compliance (push) Failing after 47s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-ai-sdk (push) Successful in 34s
Build + Deploy / build-developer-portal (push) Successful in 56s
Build + Deploy / build-tts (push) Successful in 26s
Build + Deploy / build-document-crawler (push) Successful in 15s
Build + Deploy / build-dsms-gateway (push) Successful in 13s
Build + Deploy / trigger-orca (push) Has been skipped
CI/CD / loc-budget (push) Successful in 22s
CI/CD / guardrail-integrity (push) Has been skipped
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been cancelled
CI/CD / test-go-ai-compliance (push) Has been cancelled
CI/CD / test-python-backend-compliance (push) Has been cancelled
CI/CD / test-python-document-crawler (push) Has been cancelled
CI/CD / test-python-dsms-gateway (push) Successful in 28s
CI/CD / sbom-scan (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Successful in 20s

Phase 1: backend-compliance — partial service-layer extraction
Phase 2: ai-compliance-sdk — full hexagonal split; iace/ucca/training handlers
  and stores split into focused files; cmd/server/main.go → internal/app/
Phase 3: admin-compliance — types.ts, tom-generator loader, and major page
  components split; lib document generators extracted
Phase 4: dsms-gateway, consent-sdk, developer-portal, breakpilot-compliance-sdk
Phase 5 CI hardening:
  - loc-budget job now scans whole repo (blocking, no || true)
  - sbom-scan / grype blocking on high+ CVEs
  - ai-compliance-sdk/.golangci.yml: strict golangci-lint config
  - check-loc.sh: skip test_*.py and *.html; loc-exceptions.txt expanded
  - deleted stray routes.py.backup (2512 LOC)
Docs:
  - root README.md with CI badge, service table, quick start, CI pipeline table
  - CONTRIBUTING.md: setup, pre-commit checklist, guardrail marker reference
  - CLAUDE.md: First-Time Setup & Claude Code Onboarding section
  - all 7 service READMEs updated (stale phase refs, current architecture)
  - AGENTS.go/python/typescript.md enhanced with linting, DI, barrel re-export
  - .gitignore: dist/, .turbo/, pnpm-lock.yaml added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-19 16:11:53 +02:00
1258 changed files with 210195 additions and 145532 deletions

View File

@@ -0,0 +1,53 @@
# admin-compliance
Next.js 15 dashboard for BreakPilot Compliance — SDK module UI, company profile, DSR, DSFA, VVT, TOM, consent, AI Act, training, audit, change requests, etc. Also hosts 96+ API routes that proxy/orchestrate backend services.
**Port:** `3007` (container: `bp-compliance-admin`)
**Stack:** Next.js 15 App Router, React 18, TailwindCSS, TypeScript strict.
## Architecture (Phase 3 — in progress)
```
app/
├── <route>/
│ ├── page.tsx # Server Component (≤200 LOC)
│ ├── _components/ # Colocated UI, each ≤300 LOC
│ ├── _hooks/ # Client hooks
│ └── _server/ # Server actions
├── api/<domain>/route.ts # Thin handlers → lib/server/<domain>/
lib/
├── <domain>/ # Pure helpers, zod schemas
└── server/<domain>/ # "server-only" logic
components/ # App-wide shared UI
```
See `../AGENTS.typescript.md`.
## Run locally
```bash
cd admin-compliance
npm install
npm run dev # http://localhost:3007
```
## Tests
```bash
npm test # Vitest unit + component tests
npx playwright test # E2E
npx tsc --noEmit # Type-check
npx next lint
```
## Known debt
- `lib/sdk/types.ts` has been split: it is now a barrel re-export to `lib/sdk/types/` (12 domain files: enums, company-profile, sdk-steps, and others).
- `lib/sdk/tom-generator/controls/loader.ts` has been split: it is now a barrel re-export to `categories/` (8 category files).
- Phase 3 refactoring is ongoing — several large page files remain and are being addressed incrementally.
- **0 test files** for the page layer. Adding Playwright smoke + Vitest unit coverage is ongoing Phase 3 work.
## Don't touch
- Backend API paths without updating `backend-compliance/` in the same change.
- `lib/sdk/types/` barrel re-exports — add new types to the appropriate domain file, not back into the root.

View File

@@ -0,0 +1,364 @@
/**
* Drafting Engine - v2 Pipeline Helpers
*
* DOCUMENT_PROSE_BLOCKS, buildV2SystemPrompt, buildBlockSpecificPrompt,
* callOllama, handleV2Draft — split from draft-helpers.ts for the 500 LOC hard cap.
*/
import { NextResponse } from 'next/server'
import type { DraftContext, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
import { deriveNarrativeTags, extractScoresFromDraftContext, narrativeTagsToPromptString } from '@/lib/sdk/drafting-engine/narrative-tags'
import type { NarrativeTags } from '@/lib/sdk/drafting-engine/narrative-tags'
import { buildAllowedFactsFromDraftContext, allowedFactsToPromptString, disallowedTopicsToPromptString } from '@/lib/sdk/drafting-engine/allowed-facts-v2'
import { sanitizeAllowedFacts, validateNoRemainingPII, SanitizationError } from '@/lib/sdk/drafting-engine/sanitizer'
import { terminologyToPromptString, styleContractToPromptString } from '@/lib/sdk/drafting-engine/terminology'
import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/lib/sdk/drafting-engine/prose-validator'
import { computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache'
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
import {
constraintEnforcer,
proseCache,
TEMPLATE_VERSION,
TERMINOLOGY_VERSION,
VALIDATOR_VERSION,
V1_SYSTEM_PROMPT,
buildPromptForDocumentType,
} from './draft-helpers'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
// ============================================================================
// v2 Personalisierte Pipeline
// ============================================================================
export const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType: ProseBlockOutput['blockType']; sectionName: string; targetWords: number }>> = {
tom: [
{ blockId: 'tom-intro', blockType: 'introduction', sectionName: 'Einleitung TOM', targetWords: 120 },
{ blockId: 'tom-transition', blockType: 'transition', sectionName: 'Ueberleitung Massnahmen', targetWords: 40 },
{ blockId: 'tom-conclusion', blockType: 'conclusion', sectionName: 'Fazit TOM', targetWords: 80 },
],
dsfa: [
{ blockId: 'dsfa-intro', blockType: 'introduction', sectionName: 'Einleitung DSFA', targetWords: 150 },
{ blockId: 'dsfa-transition', blockType: 'transition', sectionName: 'Ueberleitung Risikobewertung', targetWords: 40 },
{ blockId: 'dsfa-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Massnahmen', targetWords: 60 },
{ blockId: 'dsfa-conclusion', blockType: 'conclusion', sectionName: 'Fazit DSFA', targetWords: 100 },
],
vvt: [
{ blockId: 'vvt-intro', blockType: 'introduction', sectionName: 'Einleitung VVT', targetWords: 120 },
{ blockId: 'vvt-conclusion', blockType: 'conclusion', sectionName: 'Fazit VVT', targetWords: 80 },
],
dsi: [
{ blockId: 'dsi-intro', blockType: 'introduction', sectionName: 'Einleitung Datenschutzerklaerung', targetWords: 130 },
{ blockId: 'dsi-conclusion', blockType: 'conclusion', sectionName: 'Fazit Datenschutzerklaerung', targetWords: 80 },
],
lf: [
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
],
av_vertrag: [
{ blockId: 'av-intro', blockType: 'introduction', sectionName: 'Einleitung Auftragsverarbeitung', targetWords: 130 },
{ blockId: 'av-conclusion', blockType: 'conclusion', sectionName: 'Fazit Auftragsverarbeitung', targetWords: 80 },
],
betroffenenrechte: [
{ blockId: 'betr-intro', blockType: 'introduction', sectionName: 'Einleitung Betroffenenrechte', targetWords: 120 },
{ blockId: 'betr-conclusion', blockType: 'conclusion', sectionName: 'Fazit Betroffenenrechte', targetWords: 80 },
],
risikoanalyse: [
{ blockId: 'risk-intro', blockType: 'introduction', sectionName: 'Einleitung Risikoanalyse', targetWords: 130 },
{ blockId: 'risk-conclusion', blockType: 'conclusion', sectionName: 'Fazit Risikoanalyse', targetWords: 80 },
],
notfallplan: [
{ blockId: 'notfall-intro', blockType: 'introduction', sectionName: 'Einleitung Notfallplan', targetWords: 120 },
{ blockId: 'notfall-conclusion', blockType: 'conclusion', sectionName: 'Fazit Notfallplan', targetWords: 80 },
],
iace_ce_assessment: [
{ blockId: 'iace-intro', blockType: 'introduction', sectionName: 'Einleitung IACE CE-Bewertung', targetWords: 150 },
{ blockId: 'iace-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Konformitaetsbewertung', targetWords: 60 },
{ blockId: 'iace-conclusion', blockType: 'conclusion', sectionName: 'Fazit IACE CE-Bewertung', targetWords: 100 },
],
}
export function buildV2SystemPrompt(
sanitizedFactsString: string,
narrativeTagsString: string,
terminologyString: string,
styleString: string,
disallowedString: string,
companyName: string,
blockId: string,
blockType: string,
sectionName: string,
documentType: string,
targetWords: number
): string {
return `Du bist ein Compliance-Dokumenten-Redakteur.
Du schreibst einzelne Textabschnitte fuer offizielle Compliance-Dokumente.
KUNDENPROFIL (ERLAUBTE FAKTEN — nur diese darfst du verwenden):
${sanitizedFactsString}
BEWERTUNGSERGEBNIS (sprachliche Tags — verwende nur diese Begriffe):
${narrativeTagsString}
TERMINOLOGIE (verwende ausschliesslich diese Fachbegriffe):
${terminologyString}
STIL:
${styleString}
VERBOTENE INHALTE:
${disallowedString}
- Keine konkreten Prozentwerte, Scores oder Zahlen
- Keine Compliance-Level-Bezeichnungen (L1, L2, L3, L4)
- Keine direkte Ansprache ("Sie", "Ihr")
- Kein Denglisch, keine Marketing-Sprache, keine Superlative
STRIKTE REGELN:
1. Verwende den Firmennamen "${companyName}" — nie "Ihr Unternehmen"
2. Schreibe in der dritten Person ("Die ${companyName}...")
3. Beziehe dich auf die Branche und organisatorische Merkmale
4. Verwende NUR Fakten aus dem Kundenprofil oben
5. Verwende NUR die sprachlichen Tags aus dem Bewertungsergebnis
6. Erfinde KEINE zusaetzlichen Fakten oder Bewertungen
7. Halte dich an die Terminologie-Vorgaben
8. Dein Text wird ZWISCHEN feste Datentabellen eingefuegt
OUTPUT-FORMAT: Antworte ausschliesslich als JSON:
{
"blockId": "${blockId}",
"blockType": "${blockType}",
"language": "de",
"text": "...",
"assertions": {
"companyNameUsed": true/false,
"industryReferenced": true/false,
"structureReferenced": true/false,
"itLandscapeReferenced": true/false,
"narrativeTagsUsed": ["riskSummary", ...]
},
"forbiddenContentDetected": []
}
DOKUMENTENTYP: ${documentType}
SEKTION: ${sectionName}
BLOCK-TYP: ${blockType}
ZIEL-LAENGE: ${targetWords} Woerter`
}
export function buildBlockSpecificPrompt(blockType: string, sectionName: string, documentType: string): string {
switch (blockType) {
case 'introduction':
return `Schreibe eine Einleitung fuer das Dokument "${documentType}" (Sektion: ${sectionName}).
Erklaere, warum dieses Dokument fuer das Unternehmen erstellt wurde.
Gehe auf die spezifische Situation des Unternehmens ein.
Erwaehne die Branche, die Organisationsform und die IT-Strategie.`
case 'transition':
return `Schreibe eine kurze Ueberleitung zur naechsten Sektion "${sectionName}".
Verknuepfe den vorherigen Abschnitt logisch mit dem folgenden.`
case 'conclusion':
return `Schreibe einen abschliessenden Absatz fuer die Sektion "${sectionName}".
Fasse die wesentlichen Punkte zusammen und verweise auf die fortlaufende Pflege.`
case 'appreciation':
return `Schreibe einen wertschaetzenden Satz ueber die bestehenden Massnahmen.
Verwende dabei die sprachlichen Tags aus dem Bewertungsergebnis.
Keine neuen Fakten erfinden — nur das Profil wuerdigen.`
default:
return `Schreibe einen Textabschnitt fuer "${sectionName}".`
}
}
export async function callOllama(systemPrompt: string, userPrompt: string): Promise<string> {
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: LLM_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
stream: false,
think: false,
options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 },
format: 'json',
}),
signal: AbortSignal.timeout(120000),
})
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`)
}
const result = await response.json()
return result.message?.content || ''
}
export async function handleV2Draft(body: Record<string, unknown>): Promise<NextResponse> {
const { documentType, draftContext, instructions } = body as {
documentType: ScopeDocumentType
draftContext: DraftContext
instructions?: string
}
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
if (!constraintCheck.allowed) {
return NextResponse.json({
draft: null, constraintCheck, tokensUsed: 0,
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
}, { status: 403 })
}
const scores = extractScoresFromDraftContext(draftContext)
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
let sanitizationResult
try {
sanitizationResult = sanitizeAllowedFacts(allowedFacts)
} catch (error) {
if (error instanceof SanitizationError) {
return NextResponse.json({
error: `Sanitization Hard Abort: ${error.message} (Feld: ${error.field})`,
draft: null, constraintCheck, tokensUsed: 0,
}, { status: 422 })
}
throw error
}
const sanitizedFacts = sanitizationResult.facts
const piiWarnings = validateNoRemainingPII(sanitizedFacts)
if (piiWarnings.length > 0) console.warn('PII-Warnungen nach Sanitization:', piiWarnings)
const factsString = allowedFactsToPromptString(sanitizedFacts)
const tagsString = narrativeTagsToPromptString(narrativeTags)
const termsString = terminologyToPromptString()
const styleString = styleContractToPromptString()
const disallowedString = disallowedTopicsToPromptString()
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
const generatedBlocks: ProseBlockOutput[] = []
const repairAudits: RepairAudit[] = []
let totalTokens = 0
for (const blockDef of proseBlocks) {
const cacheParams: CacheKeyParams = {
allowedFacts: sanitizedFacts, templateVersion: TEMPLATE_VERSION,
terminologyVersion: TERMINOLOGY_VERSION, narrativeTags,
promptHash, blockType: blockDef.blockType, sectionName: blockDef.sectionName,
}
const cached = proseCache.getSync(cacheParams)
if (cached) {
generatedBlocks.push(cached)
repairAudits.push({ repairAttempts: 0, validatorFailures: [], repairSuccessful: true, fallbackUsed: false })
continue
}
let systemPrompt = buildV2SystemPrompt(
factsString, tagsString, termsString, styleString, disallowedString,
sanitizedFacts.companyName, blockDef.blockId, blockDef.blockType,
blockDef.sectionName, documentType, blockDef.targetWords
)
if (v2RagContext) systemPrompt += `\n\nRECHTSKONTEXT (als Referenz, nicht woertlich uebernehmen):\n${v2RagContext}`
const userPrompt = buildBlockSpecificPrompt(blockDef.blockType, blockDef.sectionName, documentType)
+ (instructions ? `\n\nZusaetzliche Anweisungen: ${instructions}` : '')
try {
const rawOutput = await callOllama(systemPrompt, userPrompt)
totalTokens += rawOutput.length / 4
const { block, audit } = await executeRepairLoop(
rawOutput, sanitizedFacts, narrativeTags, blockDef.blockId, blockDef.blockType,
async (repairPrompt) => callOllama(systemPrompt, repairPrompt), documentType
)
generatedBlocks.push(block)
repairAudits.push(audit)
if (!audit.fallbackUsed) proseCache.setSync(cacheParams, block)
} catch (error) {
const { buildFallbackBlock } = await import('@/lib/sdk/drafting-engine/prose-validator')
generatedBlocks.push(buildFallbackBlock(blockDef.blockId, blockDef.blockType, sanitizedFacts, documentType))
repairAudits.push({
repairAttempts: 0, validatorFailures: [[(error as Error).message]],
repairSuccessful: false, fallbackUsed: true,
fallbackReason: `LLM-Fehler: ${(error as Error).message}`,
})
}
}
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
let dataSections: DraftSection[] = []
try {
const dataResponse = await callOllama(V1_SYSTEM_PROMPT, draftPrompt)
const parsed = JSON.parse(dataResponse)
dataSections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
id: String(s.id || `section-${i}`), title: String(s.title || ''),
content: String(s.content || ''), schemaField: s.schemaField ? String(s.schemaField) : undefined,
}))
totalTokens += dataResponse.length / 4
} catch { dataSections = [] }
const introBlock = generatedBlocks.find(b => b.blockType === 'introduction')
const transitionBlocks = generatedBlocks.filter(b => b.blockType === 'transition')
const appreciationBlocks = generatedBlocks.filter(b => b.blockType === 'appreciation')
const conclusionBlock = generatedBlocks.find(b => b.blockType === 'conclusion')
const mergedSections: DraftSection[] = []
if (introBlock) mergedSections.push({ id: introBlock.blockId, title: 'Einleitung', content: introBlock.text })
for (let i = 0; i < dataSections.length; i++) {
if (i > 0 && transitionBlocks[i - 1]) mergedSections.push({ id: transitionBlocks[i - 1].blockId, title: '', content: transitionBlocks[i - 1].text })
mergedSections.push(dataSections[i])
}
for (const block of appreciationBlocks) mergedSections.push({ id: block.blockId, title: 'Wuerdigung', content: block.text })
if (conclusionBlock) mergedSections.push({ id: conclusionBlock.blockId, title: 'Fazit', content: conclusionBlock.text })
const finalSections = mergedSections.length > 0 ? mergedSections : generatedBlocks.map(b => ({
id: b.blockId,
title: b.blockType === 'introduction' ? 'Einleitung' : b.blockType === 'conclusion' ? 'Fazit' : b.blockType === 'appreciation' ? 'Wuerdigung' : 'Ueberleitung',
content: b.text,
}))
const draft: DraftRevision = {
id: `draft-v2-${Date.now()}`,
content: finalSections.map(s => s.title ? `## ${s.title}\n\n${s.content}` : s.content).join('\n\n'),
sections: finalSections, createdAt: new Date().toISOString(), instruction: instructions,
}
const auditTrail = {
documentType, templateVersion: TEMPLATE_VERSION, terminologyVersion: TERMINOLOGY_VERSION,
validatorVersion: VALIDATOR_VERSION, promptHash, llmModel: LLM_MODEL,
llmTemperature: 0.15, llmProvider: 'ollama', narrativeTags,
sanitization: sanitizationResult.audit, repairAudits,
proseBlocks: generatedBlocks.map((b, i) => ({
blockId: b.blockId, blockType: b.blockType,
wordCount: b.text.split(/\s+/).filter(Boolean).length,
fallbackUsed: repairAudits[i]?.fallbackUsed ?? false,
repairAttempts: repairAudits[i]?.repairAttempts ?? 0,
})),
cacheStats: proseCache.getStats(),
}
const truthLabel = { generation_mode: 'draft_assistance', truth_status: 'generated', may_be_used_as_evidence: false, generated_by: 'system' }
try {
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_type: 'document', entity_id: null, generation_mode: 'draft_assistance',
truth_status: 'generated', may_be_used_as_evidence: false,
llm_model: LLM_MODEL, llm_provider: 'ollama',
input_summary: `${documentType} draft generation`,
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
}),
}).catch(() => {/* fire-and-forget */})
} catch { /* LLM audit persistence failure should not block the response */ }
return NextResponse.json({ draft, constraintCheck, tokensUsed: Math.round(totalTokens), pipelineVersion: 'v2', auditTrail, truthLabel })
}

View File

@@ -0,0 +1,161 @@
/**
* Drafting Engine - Draft Helper Functions (v1 pipeline + shared constants)
*
* Shared state, v1 legacy pipeline helpers.
* v2 pipeline lives in draft-helpers-v2.ts.
*/
import { NextResponse } from 'next/server'
import { buildVVTDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-vvt'
import { buildTOMDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-tom'
import { buildDSFADraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-dsfa'
import { buildPrivacyPolicyDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-privacy-policy'
import { buildLoeschfristenDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-loeschfristen'
import type { DraftContext, DraftResponse, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforcer'
import { ProseCacheManager } from '@/lib/sdk/drafting-engine/cache'
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
export const constraintEnforcer = new ConstraintEnforcer()
export const proseCache = new ProseCacheManager({ maxEntries: 200, ttlHours: 24 })
export const TEMPLATE_VERSION = '2.0.0'
export const TERMINOLOGY_VERSION = '1.0.0'
export const VALIDATOR_VERSION = '1.0.0'
// ============================================================================
// v1 Legacy Pipeline
// ============================================================================
export const V1_SYSTEM_PROMPT = `Du bist ein DSGVO-Compliance-Experte und erstellst strukturierte Dokument-Entwuerfe.
Du MUSST immer im JSON-Format antworten mit einem "sections" Array.
Jede Section hat: id, title, content, schemaField.
Halte die Tiefe strikt am vorgegebenen Level.
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
Sprache: Deutsch.`
export function buildPromptForDocumentType(
documentType: ScopeDocumentType,
context: DraftContext,
instructions?: string
): string {
switch (documentType) {
case 'vvt':
return buildVVTDraftPrompt({ context, instructions })
case 'tom':
return buildTOMDraftPrompt({ context, instructions })
case 'dsfa':
return buildDSFADraftPrompt({ context, instructions })
case 'dsi':
return buildPrivacyPolicyDraftPrompt({ context, instructions })
case 'lf':
return buildLoeschfristenDraftPrompt({ context, instructions })
default:
return `## Aufgabe: Entwurf fuer ${documentType}
### Level: ${context.decisions.level}
### Tiefe: ${context.constraints.depthRequirements.depth}
### Erforderliche Inhalte:
${context.constraints.depthRequirements.detailItems.map((item, i) => `${i + 1}. ${item}`).join('\n')}
${instructions ? `### Anweisungen: ${instructions}` : ''}
Antworte als JSON mit "sections" Array.`
}
}
export async function handleV1Draft(body: Record<string, unknown>): Promise<NextResponse> {
const { documentType, draftContext, instructions, existingDraft } = body as {
documentType: ScopeDocumentType
draftContext: DraftContext
instructions?: string
existingDraft?: DraftRevision
}
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
if (!constraintCheck.allowed) {
return NextResponse.json({
draft: null,
constraintCheck,
tokensUsed: 0,
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
}, { status: 403 })
}
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
let v1SystemPrompt = V1_SYSTEM_PROMPT
if (ragContext) {
v1SystemPrompt += `\n\n## Relevanter Rechtskontext\n${ragContext}`
}
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
const messages = [
{ role: 'system', content: v1SystemPrompt },
...(existingDraft ? [{
role: 'assistant',
content: `Bisheriger Entwurf:\n${JSON.stringify(existingDraft.sections, null, 2)}`,
}] : []),
{ role: 'user', content: draftPrompt },
]
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: LLM_MODEL,
messages,
stream: false,
think: false,
options: { temperature: 0.15, num_predict: 16384, num_ctx: 8192 },
format: 'json',
}),
signal: AbortSignal.timeout(180000),
})
if (!ollamaResponse.ok) {
return NextResponse.json(
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
{ status: 502 }
)
}
const result = await ollamaResponse.json()
const content = result.message?.content || ''
let sections: DraftSection[] = []
try {
const parsed = JSON.parse(content)
sections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
id: String(s.id || `section-${i}`),
title: String(s.title || ''),
content: String(s.content || ''),
schemaField: s.schemaField ? String(s.schemaField) : undefined,
}))
} catch {
sections = [{ id: 'raw', title: 'Entwurf', content }]
}
const draft: DraftRevision = {
id: `draft-${Date.now()}`,
content: sections.map(s => `## ${s.title}\n\n${s.content}`).join('\n\n'),
sections,
createdAt: new Date().toISOString(),
instruction: instructions as string | undefined,
}
return NextResponse.json({
draft,
constraintCheck,
tokensUsed: result.eval_count || 0,
} satisfies DraftResponse)
}
// Re-export v2 handler for route.ts (backward compat — single import point)
export { handleV2Draft } from './draft-helpers-v2'

View File

@@ -9,627 +9,7 @@
*/
import { NextRequest, NextResponse } from 'next/server'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
// v1 imports (Legacy)
import { buildVVTDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-vvt'
import { buildTOMDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-tom'
import { buildDSFADraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-dsfa'
import { buildPrivacyPolicyDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-privacy-policy'
import { buildLoeschfristenDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-loeschfristen'
import type { DraftContext, DraftResponse, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforcer'
// v2 imports (Personalisierte Pipeline)
import { deriveNarrativeTags, extractScoresFromDraftContext, narrativeTagsToPromptString } from '@/lib/sdk/drafting-engine/narrative-tags'
import type { NarrativeTags } from '@/lib/sdk/drafting-engine/narrative-tags'
import { buildAllowedFactsFromDraftContext, allowedFactsToPromptString, disallowedTopicsToPromptString } from '@/lib/sdk/drafting-engine/allowed-facts-v2'
import { sanitizeAllowedFacts, validateNoRemainingPII, SanitizationError } from '@/lib/sdk/drafting-engine/sanitizer'
import { terminologyToPromptString, styleContractToPromptString } from '@/lib/sdk/drafting-engine/terminology'
import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/lib/sdk/drafting-engine/prose-validator'
import { ProseCacheManager, computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache'
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
// ============================================================================
// Shared State
// ============================================================================
const constraintEnforcer = new ConstraintEnforcer()
const proseCache = new ProseCacheManager({ maxEntries: 200, ttlHours: 24 })
// Template/Terminology Versionen (fuer Cache-Key)
const TEMPLATE_VERSION = '2.0.0'
const TERMINOLOGY_VERSION = '1.0.0'
const VALIDATOR_VERSION = '1.0.0'
// ============================================================================
// v1 Legacy Pipeline
// ============================================================================
const V1_SYSTEM_PROMPT = `Du bist ein DSGVO-Compliance-Experte und erstellst strukturierte Dokument-Entwuerfe.
Du MUSST immer im JSON-Format antworten mit einem "sections" Array.
Jede Section hat: id, title, content, schemaField.
Halte die Tiefe strikt am vorgegebenen Level.
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
Sprache: Deutsch.`
function buildPromptForDocumentType(
documentType: ScopeDocumentType,
context: DraftContext,
instructions?: string
): string {
switch (documentType) {
case 'vvt':
return buildVVTDraftPrompt({ context, instructions })
case 'tom':
return buildTOMDraftPrompt({ context, instructions })
case 'dsfa':
return buildDSFADraftPrompt({ context, instructions })
case 'dsi':
return buildPrivacyPolicyDraftPrompt({ context, instructions })
case 'lf':
return buildLoeschfristenDraftPrompt({ context, instructions })
default:
return `## Aufgabe: Entwurf fuer ${documentType}
### Level: ${context.decisions.level}
### Tiefe: ${context.constraints.depthRequirements.depth}
### Erforderliche Inhalte:
${context.constraints.depthRequirements.detailItems.map((item, i) => `${i + 1}. ${item}`).join('\n')}
${instructions ? `### Anweisungen: ${instructions}` : ''}
Antworte als JSON mit "sections" Array.`
}
}
async function handleV1Draft(body: Record<string, unknown>): Promise<NextResponse> {
const { documentType, draftContext, instructions, existingDraft } = body as {
documentType: ScopeDocumentType
draftContext: DraftContext
instructions?: string
existingDraft?: DraftRevision
}
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
if (!constraintCheck.allowed) {
return NextResponse.json({
draft: null,
constraintCheck,
tokensUsed: 0,
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
}, { status: 403 })
}
// RAG: Fetch relevant legal context (config-based)
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
let v1SystemPrompt = V1_SYSTEM_PROMPT
if (ragContext) {
v1SystemPrompt += `\n\n## Relevanter Rechtskontext\n${ragContext}`
}
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
const messages = [
{ role: 'system', content: v1SystemPrompt },
...(existingDraft ? [{
role: 'assistant',
content: `Bisheriger Entwurf:\n${JSON.stringify(existingDraft.sections, null, 2)}`,
}] : []),
{ role: 'user', content: draftPrompt },
]
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: LLM_MODEL,
messages,
stream: false,
think: false,
options: { temperature: 0.15, num_predict: 16384, num_ctx: 8192 },
format: 'json',
}),
signal: AbortSignal.timeout(180000),
})
if (!ollamaResponse.ok) {
return NextResponse.json(
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
{ status: 502 }
)
}
const result = await ollamaResponse.json()
const content = result.message?.content || ''
let sections: DraftSection[] = []
try {
const parsed = JSON.parse(content)
sections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
id: String(s.id || `section-${i}`),
title: String(s.title || ''),
content: String(s.content || ''),
schemaField: s.schemaField ? String(s.schemaField) : undefined,
}))
} catch {
sections = [{ id: 'raw', title: 'Entwurf', content }]
}
const draft: DraftRevision = {
id: `draft-${Date.now()}`,
content: sections.map(s => `## ${s.title}\n\n${s.content}`).join('\n\n'),
sections,
createdAt: new Date().toISOString(),
instruction: instructions as string | undefined,
}
return NextResponse.json({
draft,
constraintCheck,
tokensUsed: result.eval_count || 0,
} satisfies DraftResponse)
}
// ============================================================================
// v2 Personalisierte Pipeline
// ============================================================================
/** Prose block definitions per document type */
const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType: ProseBlockOutput['blockType']; sectionName: string; targetWords: number }>> = {
tom: [
{ blockId: 'tom-intro', blockType: 'introduction', sectionName: 'Einleitung TOM', targetWords: 120 },
{ blockId: 'tom-transition', blockType: 'transition', sectionName: 'Ueberleitung Massnahmen', targetWords: 40 },
{ blockId: 'tom-conclusion', blockType: 'conclusion', sectionName: 'Fazit TOM', targetWords: 80 },
],
dsfa: [
{ blockId: 'dsfa-intro', blockType: 'introduction', sectionName: 'Einleitung DSFA', targetWords: 150 },
{ blockId: 'dsfa-transition', blockType: 'transition', sectionName: 'Ueberleitung Risikobewertung', targetWords: 40 },
{ blockId: 'dsfa-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Massnahmen', targetWords: 60 },
{ blockId: 'dsfa-conclusion', blockType: 'conclusion', sectionName: 'Fazit DSFA', targetWords: 100 },
],
vvt: [
{ blockId: 'vvt-intro', blockType: 'introduction', sectionName: 'Einleitung VVT', targetWords: 120 },
{ blockId: 'vvt-conclusion', blockType: 'conclusion', sectionName: 'Fazit VVT', targetWords: 80 },
],
dsi: [
{ blockId: 'dsi-intro', blockType: 'introduction', sectionName: 'Einleitung Datenschutzerklaerung', targetWords: 130 },
{ blockId: 'dsi-conclusion', blockType: 'conclusion', sectionName: 'Fazit Datenschutzerklaerung', targetWords: 80 },
],
lf: [
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
],
av_vertrag: [
{ blockId: 'av-intro', blockType: 'introduction', sectionName: 'Einleitung Auftragsverarbeitung', targetWords: 130 },
{ blockId: 'av-conclusion', blockType: 'conclusion', sectionName: 'Fazit Auftragsverarbeitung', targetWords: 80 },
],
betroffenenrechte: [
{ blockId: 'betr-intro', blockType: 'introduction', sectionName: 'Einleitung Betroffenenrechte', targetWords: 120 },
{ blockId: 'betr-conclusion', blockType: 'conclusion', sectionName: 'Fazit Betroffenenrechte', targetWords: 80 },
],
risikoanalyse: [
{ blockId: 'risk-intro', blockType: 'introduction', sectionName: 'Einleitung Risikoanalyse', targetWords: 130 },
{ blockId: 'risk-conclusion', blockType: 'conclusion', sectionName: 'Fazit Risikoanalyse', targetWords: 80 },
],
notfallplan: [
{ blockId: 'notfall-intro', blockType: 'introduction', sectionName: 'Einleitung Notfallplan', targetWords: 120 },
{ blockId: 'notfall-conclusion', blockType: 'conclusion', sectionName: 'Fazit Notfallplan', targetWords: 80 },
],
iace_ce_assessment: [
{ blockId: 'iace-intro', blockType: 'introduction', sectionName: 'Einleitung IACE CE-Bewertung', targetWords: 150 },
{ blockId: 'iace-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Konformitaetsbewertung', targetWords: 60 },
{ blockId: 'iace-conclusion', blockType: 'conclusion', sectionName: 'Fazit IACE CE-Bewertung', targetWords: 100 },
],
}
function buildV2SystemPrompt(
sanitizedFactsString: string,
narrativeTagsString: string,
terminologyString: string,
styleString: string,
disallowedString: string,
companyName: string,
blockId: string,
blockType: string,
sectionName: string,
documentType: string,
targetWords: number
): string {
return `Du bist ein Compliance-Dokumenten-Redakteur.
Du schreibst einzelne Textabschnitte fuer offizielle Compliance-Dokumente.
KUNDENPROFIL (ERLAUBTE FAKTEN — nur diese darfst du verwenden):
${sanitizedFactsString}
BEWERTUNGSERGEBNIS (sprachliche Tags — verwende nur diese Begriffe):
${narrativeTagsString}
TERMINOLOGIE (verwende ausschliesslich diese Fachbegriffe):
${terminologyString}
STIL:
${styleString}
VERBOTENE INHALTE:
${disallowedString}
- Keine konkreten Prozentwerte, Scores oder Zahlen
- Keine Compliance-Level-Bezeichnungen (L1, L2, L3, L4)
- Keine direkte Ansprache ("Sie", "Ihr")
- Kein Denglisch, keine Marketing-Sprache, keine Superlative
STRIKTE REGELN:
1. Verwende den Firmennamen "${companyName}" — nie "Ihr Unternehmen"
2. Schreibe in der dritten Person ("Die ${companyName}...")
3. Beziehe dich auf die Branche und organisatorische Merkmale
4. Verwende NUR Fakten aus dem Kundenprofil oben
5. Verwende NUR die sprachlichen Tags aus dem Bewertungsergebnis
6. Erfinde KEINE zusaetzlichen Fakten oder Bewertungen
7. Halte dich an die Terminologie-Vorgaben
8. Dein Text wird ZWISCHEN feste Datentabellen eingefuegt
OUTPUT-FORMAT: Antworte ausschliesslich als JSON:
{
"blockId": "${blockId}",
"blockType": "${blockType}",
"language": "de",
"text": "...",
"assertions": {
"companyNameUsed": true/false,
"industryReferenced": true/false,
"structureReferenced": true/false,
"itLandscapeReferenced": true/false,
"narrativeTagsUsed": ["riskSummary", ...]
},
"forbiddenContentDetected": []
}
DOKUMENTENTYP: ${documentType}
SEKTION: ${sectionName}
BLOCK-TYP: ${blockType}
ZIEL-LAENGE: ${targetWords} Woerter`
}
function buildBlockSpecificPrompt(blockType: string, sectionName: string, documentType: string): string {
switch (blockType) {
case 'introduction':
return `Schreibe eine Einleitung fuer das Dokument "${documentType}" (Sektion: ${sectionName}).
Erklaere, warum dieses Dokument fuer das Unternehmen erstellt wurde.
Gehe auf die spezifische Situation des Unternehmens ein.
Erwaehne die Branche, die Organisationsform und die IT-Strategie.`
case 'transition':
return `Schreibe eine kurze Ueberleitung zur naechsten Sektion "${sectionName}".
Verknuepfe den vorherigen Abschnitt logisch mit dem folgenden.`
case 'conclusion':
return `Schreibe einen abschliessenden Absatz fuer die Sektion "${sectionName}".
Fasse die wesentlichen Punkte zusammen und verweise auf die fortlaufende Pflege.`
case 'appreciation':
return `Schreibe einen wertschaetzenden Satz ueber die bestehenden Massnahmen.
Verwende dabei die sprachlichen Tags aus dem Bewertungsergebnis.
Keine neuen Fakten erfinden — nur das Profil wuerdigen.`
default:
return `Schreibe einen Textabschnitt fuer "${sectionName}".`
}
}
async function callOllama(systemPrompt: string, userPrompt: string): Promise<string> {
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: LLM_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
stream: false,
think: false,
options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 },
format: 'json',
}),
signal: AbortSignal.timeout(120000),
})
if (!response.ok) {
throw new Error(`Ollama error: ${response.status}`)
}
const result = await response.json()
return result.message?.content || ''
}
async function handleV2Draft(body: Record<string, unknown>): Promise<NextResponse> {
const { documentType, draftContext, instructions } = body as {
documentType: ScopeDocumentType
draftContext: DraftContext
instructions?: string
}
// Step 1: Constraint Check (Hard Gate)
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
if (!constraintCheck.allowed) {
return NextResponse.json({
draft: null,
constraintCheck,
tokensUsed: 0,
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
}, { status: 403 })
}
// Step 2: Derive Narrative Tags (deterministisch)
const scores = extractScoresFromDraftContext(draftContext)
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
// Step 3: Build Allowed Facts
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
// Step 4: PII Sanitization
let sanitizationResult
try {
sanitizationResult = sanitizeAllowedFacts(allowedFacts)
} catch (error) {
if (error instanceof SanitizationError) {
return NextResponse.json({
error: `Sanitization Hard Abort: ${error.message} (Feld: ${error.field})`,
draft: null,
constraintCheck,
tokensUsed: 0,
}, { status: 422 })
}
throw error
}
const sanitizedFacts = sanitizationResult.facts
// Verify no remaining PII
const piiWarnings = validateNoRemainingPII(sanitizedFacts)
if (piiWarnings.length > 0) {
console.warn('PII-Warnungen nach Sanitization:', piiWarnings)
}
// Step 5: Build prompt components
const factsString = allowedFactsToPromptString(sanitizedFacts)
const tagsString = narrativeTagsToPromptString(narrativeTags)
const termsString = terminologyToPromptString()
const styleString = styleContractToPromptString()
const disallowedString = disallowedTopicsToPromptString()
// Compute prompt hash for audit
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
// Step 5b: RAG Legal Context (config-based)
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
// Step 6: Generate Prose Blocks (with cache + repair loop)
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
const generatedBlocks: ProseBlockOutput[] = []
const repairAudits: RepairAudit[] = []
let totalTokens = 0
for (const blockDef of proseBlocks) {
// Check cache
const cacheParams: CacheKeyParams = {
allowedFacts: sanitizedFacts,
templateVersion: TEMPLATE_VERSION,
terminologyVersion: TERMINOLOGY_VERSION,
narrativeTags,
promptHash,
blockType: blockDef.blockType,
sectionName: blockDef.sectionName,
}
const cached = proseCache.getSync(cacheParams)
if (cached) {
generatedBlocks.push(cached)
repairAudits.push({
repairAttempts: 0,
validatorFailures: [],
repairSuccessful: true,
fallbackUsed: false,
})
continue
}
// Build prompts
let systemPrompt = buildV2SystemPrompt(
factsString, tagsString, termsString, styleString, disallowedString,
sanitizedFacts.companyName,
blockDef.blockId, blockDef.blockType, blockDef.sectionName,
documentType, blockDef.targetWords
)
if (v2RagContext) {
systemPrompt += `\n\nRECHTSKONTEXT (als Referenz, nicht woertlich uebernehmen):\n${v2RagContext}`
}
const userPrompt = buildBlockSpecificPrompt(
blockDef.blockType, blockDef.sectionName, documentType
) + (instructions ? `\n\nZusaetzliche Anweisungen: ${instructions}` : '')
// Call LLM + Repair Loop
try {
const rawOutput = await callOllama(systemPrompt, userPrompt)
totalTokens += rawOutput.length / 4 // Rough token estimate
const { block, audit } = await executeRepairLoop(
rawOutput,
sanitizedFacts,
narrativeTags,
blockDef.blockId,
blockDef.blockType,
async (repairPrompt) => callOllama(systemPrompt, repairPrompt),
documentType
)
generatedBlocks.push(block)
repairAudits.push(audit)
// Cache successful blocks (not fallbacks)
if (!audit.fallbackUsed) {
proseCache.setSync(cacheParams, block)
}
} catch (error) {
// LLM unreachable → Fallback
const { buildFallbackBlock } = await import('@/lib/sdk/drafting-engine/prose-validator')
generatedBlocks.push(
buildFallbackBlock(blockDef.blockId, blockDef.blockType, sanitizedFacts, documentType)
)
repairAudits.push({
repairAttempts: 0,
validatorFailures: [[(error as Error).message]],
repairSuccessful: false,
fallbackUsed: true,
fallbackReason: `LLM-Fehler: ${(error as Error).message}`,
})
}
}
// Step 7: Build v1-compatible draft sections from prose blocks + original prompt
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
// Also generate data sections via legacy pipeline
let dataSections: DraftSection[] = []
try {
const dataResponse = await callOllama(V1_SYSTEM_PROMPT, draftPrompt)
const parsed = JSON.parse(dataResponse)
dataSections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
id: String(s.id || `section-${i}`),
title: String(s.title || ''),
content: String(s.content || ''),
schemaField: s.schemaField ? String(s.schemaField) : undefined,
}))
totalTokens += dataResponse.length / 4
} catch {
dataSections = []
}
// Merge: Prose intro → Data sections → Prose transitions/conclusion
const introBlock = generatedBlocks.find(b => b.blockType === 'introduction')
const transitionBlocks = generatedBlocks.filter(b => b.blockType === 'transition')
const appreciationBlocks = generatedBlocks.filter(b => b.blockType === 'appreciation')
const conclusionBlock = generatedBlocks.find(b => b.blockType === 'conclusion')
const mergedSections: DraftSection[] = []
if (introBlock) {
mergedSections.push({
id: introBlock.blockId,
title: 'Einleitung',
content: introBlock.text,
})
}
for (let i = 0; i < dataSections.length; i++) {
// Insert transition before data section (if available)
if (i > 0 && transitionBlocks[i - 1]) {
mergedSections.push({
id: transitionBlocks[i - 1].blockId,
title: '',
content: transitionBlocks[i - 1].text,
})
}
mergedSections.push(dataSections[i])
}
for (const block of appreciationBlocks) {
mergedSections.push({
id: block.blockId,
title: 'Wuerdigung',
content: block.text,
})
}
if (conclusionBlock) {
mergedSections.push({
id: conclusionBlock.blockId,
title: 'Fazit',
content: conclusionBlock.text,
})
}
// If no data sections generated, use prose blocks as sections
const finalSections = mergedSections.length > 0 ? mergedSections : generatedBlocks.map(b => ({
id: b.blockId,
title: b.blockType === 'introduction' ? 'Einleitung' :
b.blockType === 'conclusion' ? 'Fazit' :
b.blockType === 'appreciation' ? 'Wuerdigung' : 'Ueberleitung',
content: b.text,
}))
const draft: DraftRevision = {
id: `draft-v2-${Date.now()}`,
content: finalSections.map(s => s.title ? `## ${s.title}\n\n${s.content}` : s.content).join('\n\n'),
sections: finalSections,
createdAt: new Date().toISOString(),
instruction: instructions,
}
// Step 8: Build Audit Trail
const auditTrail = {
documentType,
templateVersion: TEMPLATE_VERSION,
terminologyVersion: TERMINOLOGY_VERSION,
validatorVersion: VALIDATOR_VERSION,
promptHash,
llmModel: LLM_MODEL,
llmTemperature: 0.15,
llmProvider: 'ollama',
narrativeTags,
sanitization: sanitizationResult.audit,
repairAudits,
proseBlocks: generatedBlocks.map((b, i) => ({
blockId: b.blockId,
blockType: b.blockType,
wordCount: b.text.split(/\s+/).filter(Boolean).length,
fallbackUsed: repairAudits[i]?.fallbackUsed ?? false,
repairAttempts: repairAudits[i]?.repairAttempts ?? 0,
})),
cacheStats: proseCache.getStats(),
}
// Anti-Fake-Evidence: Truth label for all LLM-generated content
const truthLabel = {
generation_mode: 'draft_assistance',
truth_status: 'generated',
may_be_used_as_evidence: false,
generated_by: 'system',
}
// Fire-and-forget: persist LLM audit trail to backend
try {
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_type: 'document',
entity_id: null,
generation_mode: 'draft_assistance',
truth_status: 'generated',
may_be_used_as_evidence: false,
llm_model: LLM_MODEL,
llm_provider: 'ollama',
input_summary: `${documentType} draft generation`,
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
}),
}).catch(() => {/* fire-and-forget */})
} catch {
// LLM audit persistence failure should not block the response
}
return NextResponse.json({
draft,
constraintCheck,
tokensUsed: Math.round(totalTokens),
pipelineVersion: 'v2',
auditTrail,
truthLabel,
})
}
import { handleV1Draft, handleV2Draft } from './draft-helpers'
// ============================================================================
// Route Handler

View File

@@ -0,0 +1,50 @@
'use client'
import Link from 'next/link'
import { Course, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
interface CourseHeaderProps {
course: Course
onDelete: () => void
}
export function CourseHeader({ course, onDelete }: CourseHeaderProps) {
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
return (
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<Link
href="/sdk/academy"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg 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="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${
course.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}>
{course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
<p className="text-sm text-gray-500 mt-1">{course.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onDelete}
className="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import { Course, Lesson, Enrollment } from '@/lib/sdk/academy/types'
interface CourseStatsProps {
course: Course
sortedLessons: Lesson[]
enrollments: Enrollment[]
completedEnrollments: number
}
export function CourseStats({ course, sortedLessons, enrollments, completedEnrollments }: CourseStatsProps) {
return (
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Lektionen</div>
<div className="text-2xl font-bold text-gray-900">{sortedLessons.length}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Dauer</div>
<div className="text-2xl font-bold text-gray-900">{course.durationMinutes} Min.</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Teilnehmer</div>
<div className="text-2xl font-bold text-blue-600">{enrollments.length}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Abgeschlossen</div>
<div className="text-2xl font-bold text-green-600">{completedEnrollments}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
interface CourseTabsProps {
activeTab: TabId
onTabChange: (tab: TabId) => void
}
const TAB_LABELS: Record<TabId, string> = {
overview: 'Uebersicht',
lessons: 'Lektionen',
enrollments: 'Einschreibungen',
videos: 'Videos',
}
export function CourseTabs({ activeTab, onTabChange }: CourseTabsProps) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px">
{(['overview', 'lessons', 'enrollments', 'videos'] as TabId[]).map(tab => (
<button
key={tab}
onClick={() => onTabChange(tab)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{TAB_LABELS[tab]}
</button>
))}
</nav>
</div>
)
}

View File

@@ -0,0 +1,59 @@
'use client'
import { Enrollment, ENROLLMENT_STATUS_INFO, isEnrollmentOverdue, getDaysUntilDeadline } from '@/lib/sdk/academy/types'
interface EnrollmentsTabProps {
enrollments: Enrollment[]
overdueEnrollments: number
}
export function EnrollmentsTab({ enrollments, overdueEnrollments }: EnrollmentsTabProps) {
return (
<div className="space-y-4">
{overdueEnrollments > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
{overdueEnrollments} ueberfaellige Einschreibung(en)
</div>
)}
{enrollments.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<p className="text-gray-500">Noch keine Einschreibungen fuer diesen Kurs.</p>
</div>
) : (
enrollments.map(enrollment => {
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
const overdue = isEnrollmentOverdue(enrollment)
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
return (
<div key={enrollment.id} className={`bg-white rounded-xl border-2 p-5 ${overdue ? 'border-red-200' : 'border-gray-200'}`}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${statusInfo?.bgColor} ${statusInfo?.color}`}>
{statusInfo?.label}
</span>
{overdue && <span className="text-xs text-red-600">Ueberfaellig</span>}
</div>
<div className="font-medium text-gray-900">{enrollment.userName}</div>
<div className="text-sm text-gray-500">{enrollment.userEmail}</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900">{enrollment.progress}%</div>
<div className="text-xs text-gray-500">
{enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`}
</div>
</div>
</div>
<div className="mt-3 w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${enrollment.progress === 100 ? 'bg-green-500' : overdue ? 'bg-red-500' : 'bg-purple-500'}`}
style={{ width: `${enrollment.progress}%` }}
/>
</div>
</div>
)
})
)}
</div>
)
}

View File

@@ -0,0 +1,239 @@
'use client'
import { Lesson, QuizQuestion } from '@/lib/sdk/academy/types'
interface LessonsTabProps {
sortedLessons: Lesson[]
selectedLesson: Lesson | null
onSelectLesson: (lesson: Lesson) => void
quizAnswers: Record<string, number>
onQuizAnswer: (answers: Record<string, number>) => void
quizResult: any
isSubmittingQuiz: boolean
onSubmitQuiz: () => void
onResetQuiz: () => void
isEditing: boolean
editTitle: string
editContent: string
onEditTitle: (v: string) => void
onEditContent: (v: string) => void
isSaving: boolean
saveMessage: { type: 'success' | 'error'; text: string } | null
onStartEdit: () => void
onCancelEdit: () => void
onSaveLesson: () => void
onApproveLesson: () => void
}
export function LessonsTab({
sortedLessons,
selectedLesson,
onSelectLesson,
quizAnswers,
onQuizAnswer,
quizResult,
isSubmittingQuiz,
onSubmitQuiz,
onResetQuiz,
isEditing,
editTitle,
editContent,
onEditTitle,
onEditContent,
isSaving,
saveMessage,
onStartEdit,
onCancelEdit,
onSaveLesson,
onApproveLesson,
}: LessonsTabProps) {
return (
<div className="grid grid-cols-3 gap-6">
{/* Lesson Navigation */}
<div className="col-span-1 bg-white rounded-xl border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Lektionen</h3>
<div className="space-y-1">
{sortedLessons.map((lesson, i) => (
<button
key={lesson.id}
onClick={() => onSelectLesson(lesson)}
className={`w-full text-left p-3 rounded-lg text-sm transition-colors ${
selectedLesson?.id === lesson.id
? 'bg-purple-50 text-purple-700 border border-purple-200'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
<span className="truncate">{lesson.title}</span>
</div>
</button>
))}
</div>
</div>
{/* Lesson Content */}
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-6">
{selectedLesson ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
{isEditing ? (
<input
type="text"
value={editTitle}
onChange={e => onEditTitle(e.target.value)}
className="text-xl font-semibold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
/>
) : (
<h2 className="text-xl font-semibold text-gray-900">{selectedLesson.title}</h2>
)}
<div className="flex items-center gap-2">
<span className={`px-3 py-1 text-xs rounded-full ${
selectedLesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
selectedLesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
}`}>
{selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'}
</span>
{selectedLesson.type !== 'quiz' && !isEditing && (
<>
<button onClick={onStartEdit} className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
Bearbeiten
</button>
<button onClick={onApproveLesson} disabled={isSaving} className="px-3 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
Freigeben
</button>
</>
)}
{isEditing && (
<>
<button onClick={onCancelEdit} className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
Abbrechen
</button>
<button onClick={onSaveLesson} disabled={isSaving} className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50">
{isSaving ? 'Speichert...' : 'Speichern'}
</button>
</>
)}
</div>
</div>
{saveMessage && (
<div className={`p-3 rounded-lg text-sm ${
saveMessage.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
}`}>
{saveMessage.text}
</div>
)}
{selectedLesson.type === 'video' && selectedLesson.videoUrl && (
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
<video src={selectedLesson.videoUrl} controls className="w-full h-full" />
</div>
)}
{isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && (
<div className="space-y-2">
<label className="text-sm text-gray-500">Inhalt (Markdown)</label>
<textarea
value={editContent}
onChange={e => onEditContent(e.target.value)}
rows={20}
className="w-full border border-gray-300 rounded-xl p-4 text-sm font-mono text-gray-800 leading-relaxed resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Markdown-Inhalt der Lektion..."
/>
<p className="text-xs text-gray-400">Unterstuetzt: # Ueberschrift, ## Unterueberschrift, - Aufzaehlung, **fett**</p>
</div>
)}
{!isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && (
<div className="prose prose-sm max-w-none">
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
{selectedLesson.contentMarkdown.split('\n').map((line, i) => {
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-gray-900 mt-6 mb-3">{line.slice(2)}</h1>
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-semibold text-gray-900 mt-5 mb-2">{line.slice(3)}</h2>
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-medium text-gray-900 mt-4 mb-2">{line.slice(4)}</h3>
if (line.startsWith('- **')) {
const parts = line.slice(2).split('**')
return <li key={i} className="ml-4 list-disc"><strong>{parts[1]}</strong>{parts[2] || ''}</li>
}
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>
if (line.trim() === '') return <br key={i} />
return <p key={i} className="mb-2">{line}</p>
})}
</div>
</div>
)}
{selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && (
<div className="space-y-6">
{selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => (
<div key={q.id} className="bg-gray-50 rounded-xl p-5">
<h4 className="font-medium text-gray-900 mb-3">Frage {qi + 1}: {q.question}</h4>
<div className="space-y-2">
{q.options.map((option: string, oi: number) => {
const isSelected = quizAnswers[q.id] === oi
const showResult = quizResult && !quizResult.error
const isCorrect = showResult && quizResult.results?.[qi]?.correct
const wasSelected = showResult && isSelected
let bgClass = 'bg-white border-gray-200 hover:border-purple-300'
if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500'
if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500'
if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500'
return (
<button
key={oi}
onClick={() => !quizResult && onQuizAnswer({ ...quizAnswers, [q.id]: oi })}
disabled={!!quizResult}
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${bgClass}`}
>
<span className="text-sm text-gray-700">{String.fromCharCode(65 + oi)}) {option}</span>
</button>
)
})}
</div>
{quizResult && !quizResult.error && quizResult.results?.[qi] && (
<div className={`mt-3 p-3 rounded-lg text-sm ${
quizResult.results[qi].correct ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation}
</div>
)}
</div>
))}
{!quizResult ? (
<button
onClick={onSubmitQuiz}
disabled={isSubmittingQuiz || Object.keys(quizAnswers).length < (selectedLesson.quizQuestions?.length || 0)}
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 font-medium"
>
{isSubmittingQuiz ? 'Wird ausgewertet...' : 'Quiz auswerten'}
</button>
) : quizResult.error ? (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">{quizResult.error}</div>
) : (
<div className={`rounded-xl p-6 text-center ${quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<div className={`text-3xl font-bold ${quizResult.passed ? 'text-green-600' : 'text-red-600'}`}>
{quizResult.score}%
</div>
<div className={`text-sm mt-1 ${quizResult.passed ? 'text-green-700' : 'text-red-700'}`}>
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig
</div>
<button onClick={onResetQuiz} className="mt-4 px-4 py-2 text-sm bg-white rounded-lg border hover:bg-gray-50">
Quiz wiederholen
</button>
</div>
)}
</div>
)}
</div>
) : (
<p className="text-gray-500 text-center py-10">Waehlen Sie eine Lektion aus.</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { Course, Lesson } from '@/lib/sdk/academy/types'
interface OverviewTabProps {
course: Course
sortedLessons: Lesson[]
}
export function OverviewTab({ course, sortedLessons }: OverviewTabProps) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Kurs-Details</h3>
<dl className="grid grid-cols-2 gap-4 text-sm">
<div><dt className="text-gray-500">Bestehensgrenze</dt><dd className="font-medium text-gray-900">{course.passingScore}%</dd></div>
<div><dt className="text-gray-500">Pflicht fuer</dt><dd className="font-medium text-gray-900">{course.requiredForRoles?.join(', ') || 'Alle'}</dd></div>
<div><dt className="text-gray-500">Erstellt am</dt><dd className="font-medium text-gray-900">{new Date(course.createdAt).toLocaleDateString('de-DE')}</dd></div>
<div><dt className="text-gray-500">Aktualisiert am</dt><dd className="font-medium text-gray-900">{new Date(course.updatedAt).toLocaleDateString('de-DE')}</dd></div>
</dl>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Lektionen ({sortedLessons.length})</h3>
<div className="space-y-2">
{sortedLessons.map((lesson, i) => (
<div key={lesson.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50">
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
{i + 1}
</div>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">{lesson.title}</div>
<div className="text-xs text-gray-500">{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}</div>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
lesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
lesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
}`}>
{lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'}
</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,71 @@
'use client'
interface VideoStatus {
status: string
lessons?: Array<{ lessonId: string; status: string }>
}
interface VideosTabProps {
videoStatus: VideoStatus | null
isGeneratingVideos: boolean
onGenerateVideos: () => void
onCheckVideoStatus: () => void
}
export function VideosTab({ videoStatus, isGeneratingVideos, onGenerateVideos, onCheckVideoStatus }: VideosTabProps) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Video-Generierung</h3>
<div className="flex gap-2">
<button onClick={onCheckVideoStatus} className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
Status pruefen
</button>
<button
onClick={onGenerateVideos}
disabled={isGeneratingVideos}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{isGeneratingVideos ? 'Wird gestartet...' : 'Videos generieren'}
</button>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4 text-sm text-blue-700">
Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert.
Konfigurieren Sie die API-Keys in den Umgebungsvariablen.
</div>
{videoStatus && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Gesamtstatus:</span>
<span className={`px-2 py-1 text-xs rounded-full ${
videoStatus.status === 'completed' ? 'bg-green-100 text-green-700' :
videoStatus.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{videoStatus.status}
</span>
</div>
{videoStatus.lessons?.map((ls) => (
<div key={ls.lessonId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Lektion {ls.lessonId.slice(-4)}</span>
<span className={`text-xs ${ls.status === 'completed' ? 'text-green-600' : 'text-gray-500'}`}>
{ls.status}
</span>
</div>
))}
</div>
)}
{!videoStatus && (
<p className="text-sm text-gray-500 text-center py-8">
Klicken Sie auf "Videos generieren" um den Prozess zu starten.
</p>
)}
</div>
</div>
)
}

View File

@@ -1,17 +1,13 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import {
Course,
Lesson,
Enrollment,
QuizQuestion,
COURSE_CATEGORY_INFO,
ENROLLMENT_STATUS_INFO,
isEnrollmentOverdue,
getDaysUntilDeadline
} from '@/lib/sdk/academy/types'
import {
fetchCourse,
@@ -20,8 +16,15 @@ import {
submitQuiz,
updateLesson,
generateVideos,
getVideoStatus
getVideoStatus,
} from '@/lib/sdk/academy/api'
import { CourseHeader } from './_components/CourseHeader'
import { CourseStats } from './_components/CourseStats'
import { CourseTabs } from './_components/CourseTabs'
import { OverviewTab } from './_components/OverviewTab'
import { LessonsTab } from './_components/LessonsTab'
import { EnrollmentsTab } from './_components/EnrollmentsTab'
import { VideosTab } from './_components/VideosTab'
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
@@ -81,8 +84,7 @@ export default function CourseDetailPage() {
const handleSubmitQuiz = async () => {
if (!selectedLesson) return
const questions = selectedLesson.quizQuestions || []
const answers = questions.map((q: QuizQuestion) => quizAnswers[q.id] ?? -1)
const answers = questions.map((q: any) => quizAnswers[q.id] ?? -1)
setIsSubmittingQuiz(true)
try {
const result = await submitQuiz(selectedLesson.id, { answers })
@@ -113,10 +115,7 @@ export default function CourseDetailPage() {
setIsSaving(true)
setSaveMessage(null)
try {
await updateLesson(selectedLesson.id, {
title: editTitle,
content_url: editContent,
})
await updateLesson(selectedLesson.id, { title: editTitle, content_url: editContent })
const updatedLesson = { ...selectedLesson, title: editTitle, contentMarkdown: editContent }
setSelectedLesson(updatedLesson)
if (course) {
@@ -138,9 +137,7 @@ export default function CourseDetailPage() {
setIsSaving(true)
setSaveMessage(null)
try {
await updateLesson(selectedLesson.id, {
description: 'approved_for_video',
})
await updateLesson(selectedLesson.id, { description: 'approved_for_video' })
const updatedLesson = { ...selectedLesson }
if (course) {
const updatedLessons = course.lessons.map(l => l.id === updatedLesson.id ? updatedLesson : l)
@@ -197,454 +194,61 @@ export default function CourseDetailPage() {
)
}
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
const sortedLessons = [...(course.lessons || [])].sort((a, b) => a.order - b.order)
const completedEnrollments = enrollments.filter(e => e.status === 'completed').length
const overdueEnrollments = enrollments.filter(e => isEnrollmentOverdue(e)).length
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<Link
href="/sdk/academy"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg 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="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${
course.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
}`}>
{course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
<p className="text-sm text-gray-500 mt-1">{course.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDeleteCourse}
className="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
<CourseHeader course={course} onDelete={handleDeleteCourse} />
<CourseStats
course={course}
sortedLessons={sortedLessons}
enrollments={enrollments}
completedEnrollments={completedEnrollments}
/>
<CourseTabs activeTab={activeTab} onTabChange={setActiveTab} />
{/* Stats Row */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Lektionen</div>
<div className="text-2xl font-bold text-gray-900">{sortedLessons.length}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Dauer</div>
<div className="text-2xl font-bold text-gray-900">{course.durationMinutes} Min.</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Teilnehmer</div>
<div className="text-2xl font-bold text-blue-600">{enrollments.length}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm text-gray-500">Abgeschlossen</div>
<div className="text-2xl font-bold text-green-600">{completedEnrollments}</div>
</div>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px">
{(['overview', 'lessons', 'enrollments', 'videos'] as TabId[]).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{{ overview: 'Uebersicht', lessons: 'Lektionen', enrollments: 'Einschreibungen', videos: 'Videos' }[tab]}
</button>
))}
</nav>
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Kurs-Details</h3>
<dl className="grid grid-cols-2 gap-4 text-sm">
<div><dt className="text-gray-500">Bestehensgrenze</dt><dd className="font-medium text-gray-900">{course.passingScore}%</dd></div>
<div><dt className="text-gray-500">Pflicht fuer</dt><dd className="font-medium text-gray-900">{course.requiredForRoles?.join(', ') || 'Alle'}</dd></div>
<div><dt className="text-gray-500">Erstellt am</dt><dd className="font-medium text-gray-900">{new Date(course.createdAt).toLocaleDateString('de-DE')}</dd></div>
<div><dt className="text-gray-500">Aktualisiert am</dt><dd className="font-medium text-gray-900">{new Date(course.updatedAt).toLocaleDateString('de-DE')}</dd></div>
</dl>
</div>
{/* Lesson List Preview */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Lektionen ({sortedLessons.length})</h3>
<div className="space-y-2">
{sortedLessons.map((lesson, i) => (
<div key={lesson.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50">
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
{i + 1}
</div>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">{lesson.title}</div>
<div className="text-xs text-gray-500">{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}</div>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
lesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
lesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
}`}>
{lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'}
</span>
</div>
))}
</div>
</div>
</div>
<OverviewTab course={course} sortedLessons={sortedLessons} />
)}
{/* Lessons Tab - with content viewer and quiz player */}
{activeTab === 'lessons' && (
<div className="grid grid-cols-3 gap-6">
{/* Lesson Navigation */}
<div className="col-span-1 bg-white rounded-xl border border-gray-200 p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Lektionen</h3>
<div className="space-y-1">
{sortedLessons.map((lesson, i) => (
<button
key={lesson.id}
onClick={() => { setSelectedLesson(lesson); setQuizResult(null); setQuizAnswers({}) }}
className={`w-full text-left p-3 rounded-lg text-sm transition-colors ${
selectedLesson?.id === lesson.id
? 'bg-purple-50 text-purple-700 border border-purple-200'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
<span className="truncate">{lesson.title}</span>
</div>
</button>
))}
</div>
</div>
{/* Lesson Content */}
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-6">
{selectedLesson ? (
<div className="space-y-6">
<div className="flex items-center justify-between">
{isEditing ? (
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="text-xl font-semibold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
/>
) : (
<h2 className="text-xl font-semibold text-gray-900">{selectedLesson.title}</h2>
)}
<div className="flex items-center gap-2">
<span className={`px-3 py-1 text-xs rounded-full ${
selectedLesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
selectedLesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
}`}>
{selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'}
</span>
{selectedLesson.type !== 'quiz' && !isEditing && (
<>
<button
onClick={handleStartEdit}
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Bearbeiten
</button>
<button
onClick={handleApproveLesson}
disabled={isSaving}
className="px-3 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
>
Freigeben
</button>
</>
)}
{isEditing && (
<>
<button
onClick={handleCancelEdit}
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSaveLesson}
disabled={isSaving}
className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
{isSaving ? 'Speichert...' : 'Speichern'}
</button>
</>
)}
</div>
</div>
{/* Save/Approve Message */}
{saveMessage && (
<div className={`p-3 rounded-lg text-sm ${
saveMessage.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
}`}>
{saveMessage.text}
</div>
)}
{/* Video Player */}
{selectedLesson.type === 'video' && selectedLesson.videoUrl && (
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
<video
src={selectedLesson.videoUrl}
controls
className="w-full h-full"
/>
</div>
)}
{/* Text Content - Edit Mode */}
{isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && (
<div className="space-y-2">
<label className="text-sm text-gray-500">Inhalt (Markdown)</label>
<textarea
value={editContent}
onChange={e => setEditContent(e.target.value)}
rows={20}
className="w-full border border-gray-300 rounded-xl p-4 text-sm font-mono text-gray-800 leading-relaxed resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Markdown-Inhalt der Lektion..."
/>
<p className="text-xs text-gray-400">Unterstuetzt: # Ueberschrift, ## Unterueberschrift, - Aufzaehlung, **fett**</p>
</div>
)}
{/* Text Content - View Mode */}
{!isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && (
<div className="prose prose-sm max-w-none">
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
{selectedLesson.contentMarkdown.split('\n').map((line, i) => {
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-gray-900 mt-6 mb-3">{line.slice(2)}</h1>
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-semibold text-gray-900 mt-5 mb-2">{line.slice(3)}</h2>
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-medium text-gray-900 mt-4 mb-2">{line.slice(4)}</h3>
if (line.startsWith('- **')) {
const parts = line.slice(2).split('**')
return <li key={i} className="ml-4 list-disc"><strong>{parts[1]}</strong>{parts[2] || ''}</li>
}
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>
if (line.trim() === '') return <br key={i} />
return <p key={i} className="mb-2">{line}</p>
})}
</div>
</div>
)}
{/* Quiz Player */}
{selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && (
<div className="space-y-6">
{selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => (
<div key={q.id} className="bg-gray-50 rounded-xl p-5">
<h4 className="font-medium text-gray-900 mb-3">Frage {qi + 1}: {q.question}</h4>
<div className="space-y-2">
{q.options.map((option: string, oi: number) => {
const isSelected = quizAnswers[q.id] === oi
const showResult = quizResult && !quizResult.error
const isCorrect = showResult && quizResult.results?.[qi]?.correct
const wasSelected = showResult && isSelected
let bgClass = 'bg-white border-gray-200 hover:border-purple-300'
if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500'
if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500'
if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500'
return (
<button
key={oi}
onClick={() => !quizResult && setQuizAnswers({ ...quizAnswers, [q.id]: oi })}
disabled={!!quizResult}
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${bgClass}`}
>
<span className="text-sm text-gray-700">{String.fromCharCode(65 + oi)}) {option}</span>
</button>
)
})}
</div>
{quizResult && !quizResult.error && quizResult.results?.[qi] && (
<div className={`mt-3 p-3 rounded-lg text-sm ${
quizResult.results[qi].correct ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation}
</div>
)}
</div>
))}
{/* Quiz Submit / Result */}
{!quizResult ? (
<button
onClick={handleSubmitQuiz}
disabled={isSubmittingQuiz || Object.keys(quizAnswers).length < (selectedLesson.quizQuestions?.length || 0)}
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 font-medium"
>
{isSubmittingQuiz ? 'Wird ausgewertet...' : 'Quiz auswerten'}
</button>
) : quizResult.error ? (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">{quizResult.error}</div>
) : (
<div className={`rounded-xl p-6 text-center ${quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
<div className={`text-3xl font-bold ${quizResult.passed ? 'text-green-600' : 'text-red-600'}`}>
{quizResult.score}%
</div>
<div className={`text-sm mt-1 ${quizResult.passed ? 'text-green-700' : 'text-red-700'}`}>
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig
</div>
<button
onClick={() => { setQuizResult(null); setQuizAnswers({}) }}
className="mt-4 px-4 py-2 text-sm bg-white rounded-lg border hover:bg-gray-50"
>
Quiz wiederholen
</button>
</div>
)}
</div>
)}
</div>
) : (
<p className="text-gray-500 text-center py-10">Waehlen Sie eine Lektion aus.</p>
)}
</div>
</div>
<LessonsTab
sortedLessons={sortedLessons}
selectedLesson={selectedLesson}
onSelectLesson={(lesson) => { setSelectedLesson(lesson); setQuizResult(null); setQuizAnswers({}) }}
quizAnswers={quizAnswers}
onQuizAnswer={setQuizAnswers}
quizResult={quizResult}
isSubmittingQuiz={isSubmittingQuiz}
onSubmitQuiz={handleSubmitQuiz}
onResetQuiz={() => { setQuizResult(null); setQuizAnswers({}) }}
isEditing={isEditing}
editTitle={editTitle}
editContent={editContent}
onEditTitle={setEditTitle}
onEditContent={setEditContent}
isSaving={isSaving}
saveMessage={saveMessage}
onStartEdit={handleStartEdit}
onCancelEdit={handleCancelEdit}
onSaveLesson={handleSaveLesson}
onApproveLesson={handleApproveLesson}
/>
)}
{/* Enrollments Tab */}
{activeTab === 'enrollments' && (
<div className="space-y-4">
{overdueEnrollments > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
{overdueEnrollments} ueberfaellige Einschreibung(en)
</div>
)}
{enrollments.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<p className="text-gray-500">Noch keine Einschreibungen fuer diesen Kurs.</p>
</div>
) : (
enrollments.map(enrollment => {
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
const overdue = isEnrollmentOverdue(enrollment)
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
return (
<div key={enrollment.id} className={`bg-white rounded-xl border-2 p-5 ${overdue ? 'border-red-200' : 'border-gray-200'}`}>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${statusInfo?.bgColor} ${statusInfo?.color}`}>
{statusInfo?.label}
</span>
{overdue && <span className="text-xs text-red-600">Ueberfaellig</span>}
</div>
<div className="font-medium text-gray-900">{enrollment.userName}</div>
<div className="text-sm text-gray-500">{enrollment.userEmail}</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-gray-900">{enrollment.progress}%</div>
<div className="text-xs text-gray-500">
{enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`}
</div>
</div>
</div>
<div className="mt-3 w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${enrollment.progress === 100 ? 'bg-green-500' : overdue ? 'bg-red-500' : 'bg-purple-500'}`}
style={{ width: `${enrollment.progress}%` }}
/>
</div>
</div>
)
})
)}
</div>
<EnrollmentsTab enrollments={enrollments} overdueEnrollments={overdueEnrollments} />
)}
{/* Videos Tab */}
{activeTab === 'videos' && (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Video-Generierung</h3>
<div className="flex gap-2">
<button
onClick={handleCheckVideoStatus}
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
>
Status pruefen
</button>
<button
onClick={handleGenerateVideos}
disabled={isGeneratingVideos}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{isGeneratingVideos ? 'Wird gestartet...' : 'Videos generieren'}
</button>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4 text-sm text-blue-700">
Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert.
Konfigurieren Sie die API-Keys in den Umgebungsvariablen.
</div>
{videoStatus && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Gesamtstatus:</span>
<span className={`px-2 py-1 text-xs rounded-full ${
videoStatus.status === 'completed' ? 'bg-green-100 text-green-700' :
videoStatus.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{videoStatus.status}
</span>
</div>
{videoStatus.lessons?.map((ls: any) => (
<div key={ls.lessonId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Lektion {ls.lessonId.slice(-4)}</span>
<span className={`text-xs ${ls.status === 'completed' ? 'text-green-600' : 'text-gray-500'}`}>
{ls.status}
</span>
</div>
))}
</div>
)}
{!videoStatus && (
<p className="text-sm text-gray-500 text-center py-8">
Klicken Sie auf "Videos generieren" um den Prozess zu starten.
</p>
)}
</div>
</div>
<VideosTab
videoStatus={videoStatus}
isGeneratingVideos={isGeneratingVideos}
onGenerateVideos={handleGenerateVideos}
onCheckVideoStatus={handleCheckVideoStatus}
/>
)}
</div>
)

View File

@@ -0,0 +1,139 @@
'use client'
import React from 'react'
import type { Certificate } from '@/lib/sdk/academy/types'
// =============================================================================
// CERTIFICATE ROW
// =============================================================================
function CertificateRow({ cert }: { cert: Certificate }) {
const now = new Date()
const validUntil = new Date(cert.validUntil)
const daysLeft = Math.ceil((validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
const isExpired = daysLeft <= 0
const isExpiringSoon = daysLeft > 0 && daysLeft <= 30
return (
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{cert.userName}</td>
<td className="px-4 py-3 text-gray-600">{cert.courseName}</td>
<td className="px-4 py-3 text-gray-500">{new Date(cert.issuedAt).toLocaleDateString('de-DE')}</td>
<td className="px-4 py-3 text-gray-500">{validUntil.toLocaleDateString('de-DE')}</td>
<td className="px-4 py-3 text-center">
{isExpired ? (
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Abgelaufen</span>
) : isExpiringSoon ? (
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700">Laeuft bald ab</span>
) : (
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Gueltig</span>
)}
</td>
<td className="px-4 py-3 text-center">
{cert.pdfUrl ? (
<a
href={cert.pdfUrl}
download
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
>
PDF Download
</a>
) : (
<span className="px-3 py-1 text-xs bg-gray-100 text-gray-400 rounded cursor-not-allowed">
Nicht verfuegbar
</span>
)}
</td>
</tr>
)
}
// =============================================================================
// CERTIFICATES TAB
// =============================================================================
export function CertificatesTab({
certificates,
certSearch,
onSearchChange
}: {
certificates: Certificate[]
certSearch: string
onSearchChange: (s: string) => void
}) {
const now = new Date()
const total = certificates.length
const valid = certificates.filter(c => new Date(c.validUntil) > now).length
const expired = certificates.filter(c => new Date(c.validUntil) <= now).length
return (
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-900">{total}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-4 text-center">
<div className="text-2xl font-bold text-green-600">{valid}</div>
<div className="text-sm text-gray-500">Gueltig</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-4 text-center">
<div className="text-2xl font-bold text-red-600">{expired}</div>
<div className="text-sm text-gray-500">Abgelaufen</div>
</div>
</div>
{/* Search */}
<div>
<input
type="text"
placeholder="Nach Mitarbeiter oder Kurs suchen..."
value={certSearch}
onChange={e => onSearchChange(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{/* Table */}
{certificates.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Noch keine Zertifikate ausgestellt</h3>
<p className="mt-2 text-gray-500">Zertifikate werden automatisch nach Kursabschluss generiert.</p>
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-700">Mitarbeiter</th>
<th className="text-left px-4 py-3 font-medium text-gray-700">Kurs</th>
<th className="text-left px-4 py-3 font-medium text-gray-700">Ausgestellt am</th>
<th className="text-left px-4 py-3 font-medium text-gray-700">Gueltig bis</th>
<th className="text-center px-4 py-3 font-medium text-gray-700">Status</th>
<th className="text-center px-4 py-3 font-medium text-gray-700">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{certificates
.filter(c =>
!certSearch ||
c.userName.toLowerCase().includes(certSearch.toLowerCase()) ||
c.courseName.toLowerCase().includes(certSearch.toLowerCase())
)
.map(cert => <CertificateRow key={cert.id} cert={cert} />)
}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { Course, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
export function CourseCard({ course, enrollmentCount, onEdit }: { course: Course; enrollmentCount: number; onEdit?: (course: Course) => void }) {
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
return (
<div className="relative group">
<Link href={`/sdk/academy/${course.id}`}>
<div className="bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer border-gray-200 hover:border-purple-300">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
{course.status === 'published' && (
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Veroeffentlicht</span>
)}
</div>
<h3 className="text-lg font-semibold text-gray-900 truncate">{course.title}</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{course.description}</p>
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
{course.lessons.length} Lektionen
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{course.durationMinutes} Min.
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{enrollmentCount} Teilnehmer
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Bestehensgrenze: {course.passingScore}%
</span>
</div>
</div>
<div className="text-right ml-4 text-gray-500">
<div className="text-sm font-medium">
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
</div>
<div className="text-xs mt-0.5">
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
</div>
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Details
</span>
</div>
</div>
</Link>
{onEdit && (
<button
onClick={(e) => { e.preventDefault(); onEdit(course) }}
className="absolute top-3 right-3 p-1.5 bg-white rounded-lg shadow border border-gray-200 text-gray-400 hover:text-purple-600 hover:border-purple-300 opacity-0 group-hover:opacity-100 transition-all z-10"
title="Kurs bearbeiten"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import React, { useState } from 'react'
import { Course, CourseCategory, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
import { updateCourse } from '@/lib/sdk/academy/api'
export function CourseEditModal({ course, onClose, onSaved }: {
course: Course
onClose: () => void
onSaved: () => void
}) {
const [title, setTitle] = useState(course.title)
const [description, setDescription] = useState(course.description)
const [category, setCategory] = useState<CourseCategory>(course.category)
const [durationMinutes, setDurationMinutes] = useState(course.durationMinutes)
const [passingScore, setPassingScore] = useState(course.passingScore ?? 70)
const [status, setStatus] = useState<'draft' | 'published'>(course.status ?? 'draft')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await updateCourse(course.id, { title, description, category, durationMinutes, passingScore, status })
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Kurs bearbeiten</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 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 border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</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)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={category}
onChange={e => setCategory(e.target.value as CourseCategory)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{Object.entries(COURSE_CATEGORY_INFO).map(([key, info]) => (
<option key={key} value={key}>{info.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
value={status}
onChange={e => setStatus(e.target.value as 'draft' | 'published')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="draft">Entwurf</option>
<option value="published">Veroeffentlicht</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Minuten)</label>
<input
type="number"
min={1}
value={durationMinutes}
onChange={e => setDurationMinutes(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
<input
type="number"
min={0}
max={100}
value={passingScore}
onChange={e => setPassingScore(Number(e.target.value))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={handleSave}
disabled={saving || !title}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{saving ? 'Speichern...' : 'Aenderungen speichern'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import React from 'react'
import {
Enrollment,
ENROLLMENT_STATUS_INFO,
isEnrollmentOverdue,
getDaysUntilDeadline
} from '@/lib/sdk/academy/types'
export function EnrollmentCard({ enrollment, courseName, onEdit, onComplete, onDelete }: {
enrollment: Enrollment
courseName: string
onEdit?: (enrollment: Enrollment) => void
onComplete?: (id: string) => void
onDelete?: (id: string) => void
}) {
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
const overdue = isEnrollmentOverdue(enrollment)
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
return (
<div className={`
bg-white rounded-xl border-2 p-6
${overdue ? 'border-red-300' :
enrollment.status === 'completed' ? 'border-green-200' :
enrollment.status === 'in_progress' ? 'border-yellow-200' :
'border-gray-200'
}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Status Badge */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
{statusInfo.label}
</span>
{overdue && (
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Ueberfaellig
</span>
)}
</div>
{/* User Info */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{enrollment.userName}
</h3>
<p className="text-sm text-gray-500">{enrollment.userEmail}</p>
<p className="text-sm text-gray-600 mt-1 font-medium">{courseName}</p>
{/* Progress Bar */}
<div className="mt-3">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">Fortschritt</span>
<span className="font-medium text-gray-700">{enrollment.progress}%</span>
</div>
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
enrollment.progress === 100 ? 'bg-green-500' :
overdue ? 'bg-red-500' :
'bg-purple-500'
}`}
style={{ width: `${enrollment.progress}%` }}
/>
</div>
</div>
</div>
{/* Right Side - Deadline */}
<div className={`text-right ml-4 ${
overdue ? 'text-red-600' :
daysUntil <= 7 ? 'text-orange-600' :
'text-gray-500'
}`}>
<div className="text-sm font-medium">
{enrollment.status === 'completed'
? 'Abgeschlossen'
: overdue
? `${Math.abs(daysUntil)} Tage ueberfaellig`
: `${daysUntil} Tage verbleibend`
}
</div>
<div className="text-xs mt-0.5">
Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
{enrollment.completedAt && (
<span className="ml-3 text-green-600">
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
<div className="flex items-center gap-2">
{enrollment.status === 'in_progress' && onComplete && (
<button
onClick={() => onComplete(enrollment.id)}
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Abschliessen
</button>
)}
{onEdit && (
<button
onClick={() => onEdit(enrollment)}
className="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Bearbeiten
</button>
)}
{onDelete && (
<button
onClick={() => onDelete(enrollment.id)}
className="px-3 py-1 text-xs bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
>
Loeschen
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import React, { useState } from 'react'
import { Enrollment } from '@/lib/sdk/academy/types'
import { updateEnrollment } from '@/lib/sdk/academy/api'
export function EnrollmentEditModal({ enrollment, onClose, onSaved }: {
enrollment: Enrollment
onClose: () => void
onSaved: () => void
}) {
const [deadline, setDeadline] = useState(enrollment.deadline ? enrollment.deadline.split('T')[0] : '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await updateEnrollment(enrollment.id, { deadline: new Date(deadline).toISOString() })
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Einschreibung bearbeiten</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4">
<div>
<p className="text-sm text-gray-500">Teilnehmer: <span className="font-medium text-gray-900">{enrollment.userName}</span></p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline</label>
<input
type="date"
value={deadline}
onChange={e => setDeadline(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={handleSave}
disabled={saving || !deadline}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,224 @@
'use client'
import React from 'react'
import Link from 'next/link'
// =============================================================================
// HEADER ACTIONS
// =============================================================================
export function HeaderActions({
isGenerating,
onGenerateAll
}: {
isGenerating: boolean
onGenerateAll: () => void
}) {
return (
<div className="flex gap-2">
<button
onClick={onGenerateAll}
disabled={isGenerating}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
>
{isGenerating ? (
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
)}
{isGenerating ? 'Generiere...' : 'Alle Kurse generieren'}
</button>
<Link
href="/sdk/academy/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Kurs erstellen
</Link>
</div>
)
}
// =============================================================================
// GENERATION RESULT BAR
// =============================================================================
export function GenerationResultBar({
result
}: {
result: { generated: number; skipped: number; errors: string[] }
}) {
return (
<div className={`p-4 rounded-lg border ${result.errors.length > 0 ? 'bg-yellow-50 border-yellow-200' : 'bg-green-50 border-green-200'}`}>
<div className="flex items-center gap-4 text-sm">
<span className="text-green-700 font-medium">{result.generated} Kurse generiert</span>
<span className="text-gray-500">{result.skipped} uebersprungen</span>
{result.errors.length > 0 && (
<span className="text-red-600">{result.errors.length} Fehler</span>
)}
</div>
{result.errors.length > 0 && (
<div className="mt-2 text-xs text-red-600">
{result.errors.map((err, i) => <div key={i}>{err}</div>)}
</div>
)}
</div>
)
}
// =============================================================================
// LOADING SPINNER
// =============================================================================
export function LoadingSpinner() {
return (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 text-purple-600" 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>
</div>
)
}
// =============================================================================
// OVERDUE ALERT
// =============================================================================
export function OverdueAlert({ count, onShow }: { count: number; onShow: () => void }) {
return (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-red-800">
Achtung: {count} ueberfaellige Schulung(en)
</h4>
<p className="text-sm text-red-600">
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
</p>
</div>
<button
onClick={onShow}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)
}
// =============================================================================
// INFO BOX
// =============================================================================
export function InfoBox() {
return (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h4 className="font-medium text-blue-800">Schulungspflicht nach Art. 39 DSGVO</h4>
<p className="text-sm text-blue-600 mt-1">
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
sollten mindestens jaehrlich aufgefrischt werden.
</p>
</div>
</div>
</div>
)
}
// =============================================================================
// EMPTY STATES
// =============================================================================
export function EmptyCourses({
selectedCategory,
onClearFilters
}: {
selectedCategory: string
onClearFilters: () => void
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Kurse gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedCategory !== 'all'
? 'Passen Sie die Filter an oder'
: 'Es sind noch keine Kurse vorhanden.'
}
</p>
{selectedCategory !== 'all' ? (
<button
onClick={onClearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
) : (
<Link
href="/sdk/academy/new"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Ersten Kurs erstellen
</Link>
)}
</div>
)
}
export function EmptyEnrollments({
selectedStatus,
onClearFilters
}: {
selectedStatus: string
onClearFilters: () => void
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Einschreibungen gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedStatus !== 'all'
? 'Passen Sie die Filter an.'
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
}
</p>
{selectedStatus !== 'all' && (
<button
onClick={onClearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,110 @@
'use client'
import React, { useState } from 'react'
export function SettingsTab({ onSaved, saved }: { onSaved: () => void; saved: boolean }) {
const SETTINGS_KEY = 'bp_academy_settings'
const loadSettings = () => {
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) return JSON.parse(raw)
} catch { /* ignore */ }
return {}
}
const defaults = { emailReminders: true, reminderDays: 7, defaultPassingScore: 70, defaultValidityDays: 365 }
const saved_settings = loadSettings()
const [emailReminders, setEmailReminders] = useState<boolean>(saved_settings.emailReminders ?? defaults.emailReminders)
const [reminderDays, setReminderDays] = useState<number>(saved_settings.reminderDays ?? defaults.reminderDays)
const [defaultPassingScore, setDefaultPassingScore] = useState<number>(saved_settings.defaultPassingScore ?? defaults.defaultPassingScore)
const [defaultValidityDays, setDefaultValidityDays] = useState<number>(saved_settings.defaultValidityDays ?? defaults.defaultValidityDays)
const handleSave = () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ emailReminders, reminderDays, defaultPassingScore, defaultValidityDays }))
onSaved()
}
return (
<div className="space-y-6 max-w-2xl">
{/* Notifications */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Benachrichtigungen</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700">E-Mail-Erinnerung bei ueberfaelligen Kursen</div>
<div className="text-xs text-gray-500">Mitarbeiter per E-Mail an ausstehende Schulungen erinnern</div>
</div>
<button
onClick={() => setEmailReminders(!emailReminders)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${emailReminders ? 'bg-purple-600' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${emailReminders ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tage vor Ablauf erinnern</label>
<input
type="number"
min={1}
max={90}
value={reminderDays}
onChange={e => setReminderDays(Number(e.target.value))}
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Course Defaults */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Standard-Einstellungen fuer neue Kurse</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard-Bestehensgrenze (%)</label>
<input
type="number"
min={0}
max={100}
value={defaultPassingScore}
onChange={e => setDefaultPassingScore(Number(e.target.value))}
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Gueltigkeitsdauer (Tage)</label>
<input
type="number"
min={1}
value={defaultValidityDays}
onChange={e => setDefaultValidityDays(Number(e.target.value))}
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-blue-700">
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. Die Gueltigkeitsdauer gilt ab dem Ausstellungsdatum.
</p>
</div>
</div>
<button
onClick={handleSave}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
saved ? 'bg-green-600 text-white' : 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{saved ? 'Gespeichert ✓' : 'Einstellungen speichern'}
</button>
</div>
)
}

View File

@@ -0,0 +1,168 @@
'use client'
import React from 'react'
import {
CourseCategory,
EnrollmentStatus,
COURSE_CATEGORY_INFO,
ENROLLMENT_STATUS_INFO
} from '@/lib/sdk/academy/types'
import { Tab, TabId } from '../_types'
// =============================================================================
// TAB NAVIGATION
// =============================================================================
export function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
// =============================================================================
// STAT CARD
// =============================================================================
export function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
{icon}
</div>
)}
</div>
</div>
)
}
// =============================================================================
// FILTER BAR
// =============================================================================
export function FilterBar({
selectedCategory,
selectedStatus,
onCategoryChange,
onStatusChange,
onClear
}: {
selectedCategory: CourseCategory | 'all'
selectedStatus: EnrollmentStatus | 'all'
onCategoryChange: (category: CourseCategory | 'all') => void
onStatusChange: (status: EnrollmentStatus | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Category Filter */}
<select
value={selectedCategory}
onChange={(e) => onCategoryChange(e.target.value as CourseCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Kategorien</option>
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
<option key={cat} value={cat}>{info.label}</option>
))}
</select>
{/* Enrollment Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as EnrollmentStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(ENROLLMENT_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,12 @@
// =============================================================================
// TYPES
// =============================================================================
export type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | 'settings'
export interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
'use client'
import React from 'react'
interface Props {
currentStep: number
isSubmitting: boolean
isEditMode: boolean
titleEmpty: boolean
onBack: () => void
onNext: () => void
onSubmit: () => void
}
export function NavigationButtons({ currentStep, isSubmitting, isEditMode, titleEmpty, onBack, onNext, onSubmit }: Props) {
return (
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
{currentStep < 8 ? (
<button
onClick={onNext}
disabled={currentStep === 1 && titleEmpty}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
Weiter
</button>
) : (
<button
onClick={onSubmit}
disabled={isSubmitting || titleEmpty}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isSubmitting ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Bewerte...
</>
) : (
isEditMode ? 'Speichern & neu bewerten' : 'Assessment starten'
)}
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import React from 'react'
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
interface Props {
result: unknown
onGoToAssessment: (id: string) => void
onGoToOverview: () => void
}
export function ResultView({ result, onGoToAssessment, onGoToOverview }: Props) {
const r = result as { assessment?: { id: string }; result?: Record<string, unknown> }
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Assessment Ergebnis</h1>
<div className="flex gap-2">
{r.assessment?.id && (
<button
onClick={() => onGoToAssessment(r.assessment!.id)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Zum Assessment
</button>
)}
<button
onClick={onGoToOverview}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Zur Uebersicht
</button>
</div>
</div>
{r.result && (
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import React from 'react'
import type { StepProps } from '../_types'
import { AI_USE_CATEGORIES } from '../_data'
interface Props extends StepProps {
profileIndustry: string | string[] | undefined
}
export function Step1Basics({ form, updateForm, profileIndustry }: Props) {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Grundlegende Informationen</h2>
{/* Branche aus Profil (nur Anzeige) */}
{profileIndustry && (Array.isArray(profileIndustry) ? profileIndustry.length > 0 : true) && (
<div className="bg-gray-50 rounded-lg border border-gray-200 px-4 py-3">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Branche (aus Unternehmensprofil)</span>
<p className="text-sm text-gray-900 mt-0.5">
{Array.isArray(profileIndustry) ? profileIndustry.join(', ') : profileIndustry}
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel des Anwendungsfalls</label>
<input
type="text"
value={form.title}
onChange={e => updateForm({ title: e.target.value })}
placeholder="z.B. Chatbot fuer Kundenservice"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={form.use_case_text}
onChange={e => updateForm({ use_case_text: e.target.value })}
rows={4}
placeholder="Beschreiben Sie den Anwendungsfall..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{/* KI-Anwendungskategorie als Kacheln */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
In welchem Bereich kommt KI zum Einsatz?
</label>
<p className="text-sm text-gray-500 mb-3">Waehlen Sie die passende Kategorie fuer Ihren Anwendungsfall.</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{AI_USE_CATEGORIES.map(cat => (
<button
key={cat.value}
type="button"
onClick={() => updateForm({ category: cat.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.category === cat.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{cat.icon}</span>
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{cat.desc}</p>
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import React from 'react'
import type { StepProps } from '../_types'
import { DATA_CATEGORY_GROUPS } from '../_data-categories'
import { toggleInArray } from '../_data'
export function Step2DataCategories({ form, updateForm }: StepProps) {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Welche Daten werden verarbeitet?</h2>
<p className="text-sm text-gray-500">Waehlen Sie alle Datenkategorien, die in diesem Use Case verarbeitet werden.</p>
{DATA_CATEGORY_GROUPS.map(group => (
<div key={group.group}>
<h3 className={`text-sm font-semibold mb-2 ${group.art9 ? 'text-orange-700' : 'text-gray-700'}`}>
{group.art9 && '⚠️ '}{group.group}
</h3>
{group.art9 && (
<p className="text-xs text-orange-600 mb-2">Besonders schutzwuerdig erhoehte Anforderungen an Rechtsgrundlage und TOM</p>
)}
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-4">
{group.items.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ data_categories: toggleInArray(form.data_categories, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.data_categories.includes(item.value)
? group.art9
? 'border-orange-500 bg-orange-50 ring-1 ring-orange-300'
: 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
))}
{/* Sonstige Datentypen */}
<div className="border border-gray-200 rounded-lg p-4 space-y-3">
<div className="font-medium text-gray-900">Sonstige Datentypen</div>
<p className="text-sm text-gray-500">
Falls Ihre Datenkategorie oben nicht aufgefuehrt ist, koennen Sie sie hier ergaenzen.
</p>
{form.custom_data_types.map((dt, idx) => (
<div key={idx} className="flex items-center gap-2">
<input
type="text"
value={dt}
onChange={e => {
const updated = [...form.custom_data_types]
updated[idx] = e.target.value
updateForm({ custom_data_types: updated })
}}
placeholder="Datentyp eingeben..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<button
onClick={() => updateForm({ custom_data_types: form.custom_data_types.filter((_, i) => i !== idx) })}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
title="Entfernen"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
))}
<button
onClick={() => updateForm({ custom_data_types: [...form.custom_data_types, ''] })}
className="flex items-center gap-1 text-sm text-purple-600 hover:text-purple-700 font-medium"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
Weiteren Datentyp hinzufuegen
</button>
</div>
{form.data_categories.length > 0 && (
<div className="bg-purple-50 border border-purple-200 rounded-lg px-4 py-3 text-sm text-purple-800">
<span className="font-medium">{form.data_categories.length}</span> Datenkategorie{form.data_categories.length !== 1 ? 'n' : ''} ausgewaehlt
{form.data_categories.some(c => DATA_CATEGORY_GROUPS.find(g => g.art9)?.items.some(i => i.value === c)) && (
<span className="ml-2 text-orange-700 font-medium"> inkl. besonderer Kategorien (Art. 9)</span>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import React from 'react'
import type { StepProps } from '../_types'
import { PURPOSE_TILES } from '../_tiles'
import { toggleInArray } from '../_data'
export function Step3Purposes({ form, updateForm }: StepProps) {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Zweck der Verarbeitung</h2>
<p className="text-sm text-gray-500">Waehlen Sie alle zutreffenden Verarbeitungszwecke. Die passende Rechtsgrundlage wird vom SDK automatisch ermittelt.</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{PURPOSE_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ purposes: toggleInArray(form.purposes, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.purposes.includes(item.value)
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
{form.purposes.includes('profiling') && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
<div className="font-medium mb-1">Hinweis: Profiling</div>
<p>Profiling unterliegt besonderen Anforderungen nach Art. 22 DSGVO. Betroffene haben das Recht auf Information und Widerspruch.</p>
</div>
)}
{form.purposes.includes('automated_decision') && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">
<div className="font-medium mb-1">Achtung: Automatisierte Entscheidung</div>
<p>Art. 22 DSGVO: Vollautomatisierte Entscheidungen mit rechtlicher Wirkung erfordern besondere Schutzmassnahmen, Informationspflichten und das Recht auf menschliche Ueberpruefung.</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
import React from 'react'
import type { StepProps } from '../_types'
import { AUTOMATION_TILES } from '../_tiles'
export function Step4Automation({ form, updateForm }: StepProps) {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2>
<p className="text-sm text-gray-600">
Wie stark greift die KI in Entscheidungen ein? Je hoeher der Automatisierungsgrad, desto strenger die regulatorischen Anforderungen.
</p>
<div className="grid grid-cols-1 gap-3">
{AUTOMATION_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ automation: item.value })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
form.automation === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3 mb-1">
<span className="text-2xl">{item.icon}</span>
<span className="text-sm font-semibold text-gray-900">{item.label}</span>
</div>
<p className="text-sm text-gray-500 ml-11">{item.desc}</p>
<p className="text-xs text-gray-400 ml-11 mt-1">Beispiele: {item.examples}</p>
</button>
))}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<div className="font-medium mb-1">Warum ist das wichtig?</div>
<p>
Art. 22 DSGVO regelt automatisierte Einzelentscheidungen. Vollautomatisierte Systeme, die Personen
erheblich beeinflussen (z.B. Kreditvergabe, Bewerbungsauswahl), unterliegen strengen Auflagen:
Informationspflicht, Recht auf menschliche Ueberpruefung und Anfechtungsmoeglichkeit.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,105 @@
'use client'
import React from 'react'
import type { StepProps } from '../_types'
import { HOSTING_PROVIDER_TILES, HOSTING_REGION_TILES, MODEL_USAGE_TILES } from '../_tiles'
import { toggleInArray } from '../_data'
export function Step5Hosting({ form, updateForm }: StepProps) {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Technische Details</h2>
{/* Hosting Provider */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Hosting-Anbieter</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{HOSTING_PROVIDER_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ hosting_provider: item.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.hosting_provider === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
{/* Hosting Region */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Hosting-Region</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{HOSTING_REGION_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ hosting_region: item.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.hosting_region === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
{/* Model Usage */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-1">Wie wird das KI-Modell genutzt?</h3>
<p className="text-sm text-gray-500 mb-3">Waehlen Sie alle zutreffenden Optionen.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{MODEL_USAGE_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ model_usage: toggleInArray(form.model_usage, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.model_usage.includes(item.value)
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
{/* Info-Box: Begriffe erklaert */}
<details className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
<summary className="px-4 py-3 text-sm font-medium text-amber-800 cursor-pointer hover:bg-amber-100">
Begriffe erklaert: ML, DL, NLP, LLM &mdash; Was bedeutet das?
</summary>
<div className="px-4 pb-4 space-y-3 text-sm text-amber-900">
<div><span className="font-semibold">ML (Machine Learning)</span> &mdash; Computer lernt Muster aus Daten. Beispiel: Spam-Filter.</div>
<div><span className="font-semibold">DL (Deep Learning)</span> &mdash; ML mit neuronalen Netzen. Beispiel: Bilderkennung, Spracherkennung.</div>
<div><span className="font-semibold">NLP (Natural Language Processing)</span> &mdash; KI versteht Sprache. Beispiel: ChatGPT, DeepL.</div>
<div><span className="font-semibold">LLM (Large Language Model)</span> &mdash; Grosses Sprachmodell. Beispiel: GPT-4, Claude, Llama.</div>
<div><span className="font-semibold">RAG</span> &mdash; LLM erhaelt Kontext aus eigener Datenbank. Vorteil: Aktuelle, firmenspezifische Antworten.</div>
<div><span className="font-semibold">Fine-Tuning</span> &mdash; Bestehendes Modell mit eigenen Daten weitertrainieren. Achtung: Daten werden Teil des Modells.</div>
</div>
</details>
</div>
)
}

View File

@@ -0,0 +1,83 @@
'use client'
import React from 'react'
import type { StepProps } from '../_types'
import { TRANSFER_TARGET_TILES, TRANSFER_MECHANISM_TILES } from '../_tiles'
import { toggleInArray } from '../_data'
export function Step6Transfer({ form, updateForm }: StepProps) {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Internationaler Datentransfer</h2>
<p className="text-sm text-gray-500">Wohin werden die Daten uebermittelt? Waehlen Sie alle zutreffenden Ziellaender/-regionen.</p>
{/* Transfer Targets */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Datentransfer-Ziele</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{TRANSFER_TARGET_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ transfer_targets: toggleInArray(form.transfer_targets, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.transfer_targets.includes(item.value)
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
{/* Transfer Mechanism — only if not "no_transfer" only */}
{form.transfer_targets.length > 0 && !form.transfer_targets.every(t => t === 'no_transfer') && (
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Transfer-Mechanismus</h3>
<p className="text-sm text-gray-500 mb-3">Welche Schutzgarantie nutzen Sie fuer den Drittlandtransfer?</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TRANSFER_MECHANISM_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ transfer_mechanism: item.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.transfer_mechanism === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
)}
{/* Specific countries text input */}
{form.transfer_targets.some(t => !['no_transfer'].includes(t)) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Konkrete Ziellaender (optional)</label>
<input
type="text"
value={form.transfer_countries.join(', ')}
onChange={e => updateForm({ transfer_countries: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
placeholder="z.B. USA, UK, Schweiz, Japan"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<p className="text-xs text-gray-500 mt-1">Kommagetrennte Laendernamen oder -kuerzel</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import React from 'react'
import type { StepProps } from '../_types'
import { RETENTION_TILES } from '../_tiles'
export function Step7Retention({ form, updateForm }: StepProps) {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2>
<p className="text-sm text-gray-500">Wie lange sollen die Daten gespeichert werden?</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{RETENTION_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ retention_period: item.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.retention_period === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Zweck der Aufbewahrung (optional)
</label>
<textarea
value={form.retention_purpose}
onChange={e => updateForm({ retention_purpose: e.target.value })}
rows={2}
placeholder="z.B. Vertragliche Pflichten, gesetzliche Aufbewahrungsfristen..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{form.retention_period === 'indefinite' && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
<div className="font-medium mb-1">Hinweis: Unbefristete Speicherung</div>
<p>Die DSGVO fordert Datenminimierung und Speicherbegrenzung (Art. 5 Abs. 1e). Unbefristete Speicherung muss besonders gut begruendet sein.</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import React from 'react'
import type { StepProps } from '../_types'
import { CONTRACT_TILES } from '../_tiles'
import { toggleInArray } from '../_data'
export function Step8Contracts({ form, updateForm }: StepProps) {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Vertraege & Compliance-Dokumentation</h2>
<p className="text-sm text-gray-500">Welche Compliance-Dokumente liegen bereits vor? (Mehrfachauswahl moeglich)</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{CONTRACT_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ contracts: toggleInArray(form.contracts, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.contracts.includes(item.value)
? item.value === 'none'
? 'border-amber-500 bg-amber-50 ring-1 ring-amber-300'
: 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Subprozessoren (optional)</label>
<textarea
value={form.subprocessors}
onChange={e => updateForm({ subprocessors: e.target.value })}
rows={2}
placeholder="z.B. OpenAI (USA, SCC), Hetzner Cloud (DE)..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
'use client'
import React from 'react'
import { WIZARD_STEPS } from '../_data'
interface Props {
currentStep: number
onStepClick: (id: number) => void
}
export function StepIndicator({ currentStep, onStepClick }: Props) {
return (
<div className="flex items-center gap-2">
{WIZARD_STEPS.map((step, idx) => (
<React.Fragment key={step.id}>
<button
onClick={() => onStepClick(step.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
currentStep === step.id
? 'bg-purple-600 text-white'
: currentStep > step.id
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
{currentStep > step.id ? '✓' : step.id}
</span>
<span className="hidden md:inline">{step.title}</span>
</button>
{idx < WIZARD_STEPS.length - 1 && <div className="flex-1 h-px bg-gray-200" />}
</React.Fragment>
))}
</div>
)
}

View File

@@ -0,0 +1,112 @@
// =============================================================================
// DATA CATEGORIES (Step 2) — grouped tile selection
// =============================================================================
export const DATA_CATEGORY_GROUPS = [
{
group: 'Stamm- & Kontaktdaten',
items: [
{ value: 'basic_identity', label: 'Name & Identitaet', icon: '👤', desc: 'Vor-/Nachname, Geburtsdatum, Geschlecht' },
{ value: 'contact_data', label: 'Kontaktdaten', icon: '📧', desc: 'E-Mail, Telefon, Fax' },
{ value: 'address_data', label: 'Adressdaten', icon: '🏠', desc: 'Wohn-/Meldeadresse, PLZ, Lieferadresse' },
{ value: 'government_ids', label: 'Ausweisdaten', icon: '🪪', desc: 'Personalausweis-Nr., Reisepass, Fuehrerschein' },
{ value: 'customer_ids', label: 'Kundennummern', icon: '🏷️', desc: 'Kunden-ID, Vertrags-Nr., Mitgliedsnummer' },
],
},
{
group: 'Besondere Kategorien (Art. 9 DSGVO)',
art9: true,
items: [
{ value: 'health_data', label: 'Gesundheitsdaten', icon: '🏥', desc: 'Diagnosen, Medikation, AU, Pflegegrad' },
{ value: 'biometric_data', label: 'Biometrische Daten', icon: '🔐', desc: 'Fingerabdruck, Gesichtserkennung, Iris-Scan' },
{ value: 'genetic_data', label: 'Genetische Daten', icon: '🧬', desc: 'DNA-Profil, Genomsequenzen, Erbkrankheitstests' },
{ value: 'racial_ethnic', label: 'Ethnische Herkunft', icon: '🌍', desc: 'Rassische/ethnische Zugehoerigkeit' },
{ value: 'political_opinions', label: 'Politische Meinungen', icon: '🗳️', desc: 'Politische Ueberzeugungen, Parteizugehoerigkeit' },
{ value: 'religious_beliefs', label: 'Religion', icon: '🕊️', desc: 'Religionszugehoerigkeit, Weltanschauung' },
{ value: 'trade_union', label: 'Gewerkschaft', icon: '🤝', desc: 'Gewerkschaftsmitgliedschaft' },
{ value: 'sexual_orientation', label: 'Sexuelle Orientierung', icon: '🏳️‍🌈', desc: 'Sexualleben und Orientierung' },
],
},
{
group: 'Finanz- & Steuerdaten',
items: [
{ value: 'bank_account', label: 'Bankverbindung', icon: '🏦', desc: 'IBAN, BIC, Kontonummer' },
{ value: 'payment_card', label: 'Zahlungskarten', icon: '💳', desc: 'Kreditkarten-Nr., CVV (PCI-DSS)' },
{ value: 'transaction_data', label: 'Transaktionsdaten', icon: '🧾', desc: 'Zahlungshistorie, Ueberweisungen, Kaufhistorie' },
{ value: 'credit_score', label: 'Bonitaet / Schufa', icon: '📈', desc: 'Kreditwuerdigkeit, Schuldenhistorie' },
{ value: 'income_salary', label: 'Einkommen & Gehalt', icon: '💰', desc: 'Bruttogehalt, Nettolohn, Boni' },
{ value: 'tax_ids', label: 'Steuer-IDs', icon: '📋', desc: 'Steuer-ID, Steuernummer, USt-IdNr.' },
{ value: 'insurance_data', label: 'Versicherungsdaten', icon: '☂️', desc: 'Versicherungsnummern, Policen, Schadenmeldungen' },
],
},
{
group: 'Fahrzeug- & Mobilitaetsdaten',
items: [
{ value: 'vehicle_ids', label: 'Fahrzeug-IDs (VIN)', icon: '🚗', desc: 'Fahrgestellnummer (VIN/FIN), Fahrzeugschein' },
{ value: 'license_plates', label: 'Kennzeichen', icon: '🔢', desc: 'Amtliches Kennzeichen, Wunschkennzeichen' },
{ value: 'gps_tracking', label: 'GPS & Routen', icon: '📍', desc: 'Echtzeitposition, Fahrtenprotokolle' },
{ value: 'telematics', label: 'Telematikdaten', icon: '📡', desc: 'Fahrverhalten, Geschwindigkeit, Motordiagnose' },
{ value: 'fleet_data', label: 'Fuhrpark / Logistik', icon: '🚛', desc: 'Einsatzzeiten, Kilometerstand, Fahrerzuweisung' },
],
},
{
group: 'Technische Identifikatoren',
items: [
{ value: 'ip_address', label: 'IP-Adresse', icon: '🌐', desc: 'IPv4/IPv6 (EuGH: personenbezogen)' },
{ value: 'device_ids', label: 'Geraete-IDs', icon: '📱', desc: 'IMEI, UUID, Advertising-ID, Seriennummer' },
{ value: 'cookies_tracking', label: 'Cookies & Tracking', icon: '🍪', desc: 'Session-/Persistent Cookies, Pixel-Tags' },
{ value: 'browser_fingerprint', label: 'Browser-Fingerprint', icon: '🔎', desc: 'Browser-Typ, OS, Plugins, Canvas-Fingerprint' },
{ value: 'mac_address', label: 'MAC-Adresse', icon: '📶', desc: 'Netzwerkadapter-Kennung, WLAN-Praesenz' },
],
},
{
group: 'Verhaltens- & Nutzungsdaten',
items: [
{ value: 'clickstream', label: 'Klick- & Nutzungspfade', icon: '🖱️', desc: 'Klickpfade, Scrolltiefe, Verweildauer, Heatmaps' },
{ value: 'purchase_history', label: 'Kaufverhalten', icon: '🛒', desc: 'Bestellhistorie, Warenkorb, Wunschlisten' },
{ value: 'app_usage', label: 'App-Nutzung', icon: '📲', desc: 'Genutzte Apps, Nutzungsdauer, In-App-Aktivitaeten' },
{ value: 'profiling_scores', label: 'Profiling / Scoring', icon: '📊', desc: 'KI-generierte Profile, Segmente, Affinitaetsscores' },
],
},
{
group: 'Kommunikation & Medien',
items: [
{ value: 'email_content', label: 'E-Mail-Inhalte', icon: '✉️', desc: 'E-Mail-Texte, Anhaenge, Metadaten' },
{ value: 'chat_messages', label: 'Chat & Messaging', icon: '💬', desc: 'Textnachrichten, Messenger, Teams, Slack' },
{ value: 'call_recordings', label: 'Telefonaufzeichnungen', icon: '📞', desc: 'Gespraeche, Transkripte, Anrufmetadaten' },
{ value: 'video_conference', label: 'Videokonferenzen', icon: '📹', desc: 'Meeting-Aufzeichnungen, Teilnehmerlisten' },
{ value: 'photographs', label: 'Fotos & Bilder', icon: '📷', desc: 'Portraitfotos, Profilbilder, Produktfotos' },
{ value: 'cctv_surveillance', label: 'Videoueberwachung', icon: '📹', desc: 'CCTV-Aufnahmen, Zutrittskontrolle' },
{ value: 'voice_recordings', label: 'Sprachaufnahmen', icon: '🎙️', desc: 'Voicemails, Sprachmemos, Diktate' },
],
},
{
group: 'HR & Beschaeftigung',
items: [
{ value: 'employment_data', label: 'Beschaeftigungsdaten', icon: '💼', desc: 'Arbeitgeber, Berufsbezeichnung, Vertragsart' },
{ value: 'performance_data', label: 'Leistungsbeurteilungen', icon: '🏆', desc: 'Zielerreichung, Feedback, Abmahnungen' },
{ value: 'work_time', label: 'Arbeitszeit', icon: '⏰', desc: 'Zeiterfassung, Ueberstunden, Schichtplaene' },
{ value: 'candidate_data', label: 'Bewerberdaten', icon: '📝', desc: 'Lebenslaeufe, Interviews, Assessment-Ergebnisse' },
{ value: 'social_security', label: 'Sozialversicherungs-Nr.', icon: '🛡️', desc: 'RVNR (Art. 9 — kodiert Geburtsdatum/Geschlecht)' },
],
},
{
group: 'IoT & Sensordaten',
items: [
{ value: 'industrial_sensor', label: 'Industriesensoren', icon: '🏭', desc: 'Maschinendaten, Fehlerprotokolle, Produktionsmesswerte' },
{ value: 'wearable_data', label: 'Wearable-Daten', icon: '⌚', desc: 'Herzfrequenz, Schritte, Schlaf (Art. 9 — Gesundheit)' },
{ value: 'smart_home', label: 'Smart-Home', icon: '🏡', desc: 'Heizung, Licht, Bewegungsmelder, Nutzungszeiten' },
{ value: 'energy_data', label: 'Energieverbrauch', icon: '🔌', desc: 'Smart-Meter, Verbrauchsprofil (enthuellt Verhalten)' },
],
},
{
group: 'Sonstige Kategorien',
items: [
{ value: 'children_data', label: 'Kinderdaten (unter 16)', icon: '👶', desc: 'Besonderer Schutz, Eltern-Einwilligung erforderlich' },
{ value: 'criminal_data', label: 'Strafrechtliche Daten', icon: '⚖️', desc: 'Vorstrafen, Ermittlungsverfahren (Art. 10 DSGVO)' },
{ value: 'location_data', label: 'Standortdaten', icon: '📍', desc: 'GPS, Mobilfunk, WLAN-Ortung, Bewegungsprofile' },
{ value: 'social_media', label: 'Social-Media-Daten', icon: '📱', desc: 'Profile, Posts, Follower, Interaktionen' },
{ value: 'auth_credentials', label: 'Login & Zugangsdaten', icon: '🔑', desc: 'Passwoerter, 2FA, Session-Tokens, Zugriffsprotokolle' },
],
},
]

View File

@@ -0,0 +1,62 @@
// =============================================================================
// WIZARD STEPS CONFIG
// =============================================================================
export const WIZARD_STEPS = [
{ id: 1, title: 'Grundlegendes', description: 'Titel, Beschreibung und KI-Kategorie' },
{ id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
{ id: 3, title: 'Verarbeitungszweck', description: 'Zweck der Datenverarbeitung' },
{ id: 4, title: 'Automatisierung', description: 'Grad der Automatisierung' },
{ id: 5, title: 'Hosting & Modell', description: 'Technische Details' },
{ id: 6, title: 'Datentransfer', description: 'Internationaler Datentransfer' },
{ id: 7, title: 'Datenhaltung', description: 'Aufbewahrung und Speicherung' },
{ id: 8, title: 'Vertraege', description: 'Compliance und Vereinbarungen' },
]
// =============================================================================
// KI-Anwendungskategorien als Auswahlkacheln
// =============================================================================
export const AI_USE_CATEGORIES = [
{ value: 'content_generation', label: 'Content-Erstellung', icon: '✍️', desc: 'Texte, Berichte, E-Mails, Dokumentation automatisch erstellen' },
{ value: 'image_generation', label: 'Bilder erstellen', icon: '🎨', desc: 'KI-generierte Bilder, Grafiken, Produktfotos' },
{ value: 'marketing_material', label: 'Marketingmaterial', icon: '📢', desc: 'Werbetexte, Social Media Posts, Newsletter generieren' },
{ value: 'customer_service', label: 'Kundenservice / Chatbot', icon: '💬', desc: 'Automatisierte Kundenanfragen, FAQ-Bots, Support-Tickets' },
{ value: 'crm_analytics', label: 'CRM & Kundenanalyse', icon: '👥', desc: 'Kundensegmentierung, Churn-Vorhersage, Lead-Scoring' },
{ value: 'hr_recruiting', label: 'Bewerberauswahl / HR', icon: '🧑‍💼', desc: 'CV-Screening, Matching, Mitarbeiteranalysen' },
{ value: 'financial_analysis', label: 'Finanzdaten analysieren', icon: '📊', desc: 'Buchhaltung, Forecasting, Betrugs­erkennung, Risikobewertung' },
{ value: 'predictive_maintenance', label: 'Predictive Maintenance', icon: '🔧', desc: 'Vorausschauende Wartung, Ausfallvorhersage, IoT-Sensoranalyse' },
{ value: 'production_analytics', label: 'Produktionsdaten­auswertung', icon: '🏭', desc: 'Qualitaetskontrolle, Prozess­optimierung, OEE-Analyse' },
{ value: 'document_analysis', label: 'Dokumentenanalyse', icon: '📄', desc: 'Vertraege, Rechnungen, PDFs automatisch auswerten und klassifizieren' },
{ value: 'code_development', label: 'Softwareentwicklung', icon: '💻', desc: 'Code-Generierung, Code-Review, Test-Erstellung, Dokumentation' },
{ value: 'translation', label: 'Uebersetzung', icon: '🌍', desc: 'Automatische Uebersetzung von Texten, Dokumenten, Webinhalten' },
{ value: 'search_knowledge', label: 'Wissensmanagement / Suche', icon: '🔍', desc: 'Interne Wissensdatenbank, RAG-basierte Suche, FAQ-Systeme' },
{ value: 'data_extraction', label: 'Datenextraktion', icon: '⛏️', desc: 'OCR, Formularerkennung, strukturierte Daten aus Freitext' },
{ value: 'risk_compliance', label: 'Risiko & Compliance', icon: '⚖️', desc: 'Compliance-Pruefung, Risikobewertung, Audit-Unterstuetzung' },
{ value: 'supply_chain', label: 'Lieferkette & Logistik', icon: '🚛', desc: 'Bedarfsprognose, Routenoptimierung, Bestandsmanagement' },
{ value: 'medical_health', label: 'Medizin & Gesundheit', icon: '🏥', desc: 'Diagnoseunterstuetzung, Bildanalyse, Patientendaten' },
{ value: 'security_monitoring', label: 'Sicherheit & Monitoring', icon: '🛡️', desc: 'Anomalieerkennung, Bedrohungsanalyse, Zugriffskontrolle' },
{ value: 'personalization', label: 'Personalisierung', icon: '🎯', desc: 'Produktempfehlungen, dynamische Preisgestaltung, A/B-Testing' },
{ value: 'voice_speech', label: 'Sprache & Audio', icon: '🎙️', desc: 'Spracherkennung, Text-to-Speech, Meeting-Transkription' },
{ value: 'other', label: 'Sonstiges', icon: '', desc: 'Anderer KI-Anwendungsfall' },
]
// Map Profil-Branche to domain value for backend compatibility
export function industryToDomain(industries: string[]): string {
if (!industries || industries.length === 0) return 'general'
const first = industries[0].toLowerCase()
if (first.includes('gesundheit') || first.includes('pharma')) return 'healthcare'
if (first.includes('finanz') || first.includes('versicherung')) return 'finance'
if (first.includes('bildung')) return 'education'
if (first.includes('handel') || first.includes('commerce')) return 'retail'
if (first.includes('it') || first.includes('technologie')) return 'it_services'
if (first.includes('beratung') || first.includes('consulting')) return 'consulting'
if (first.includes('produktion') || first.includes('industrie') || first.includes('maschinenbau')) return 'manufacturing'
if (first.includes('marketing') || first.includes('agentur')) return 'marketing'
if (first.includes('recht')) return 'legal'
return 'general'
}
export function toggleInArray(arr: string[], value: string): string[] {
return arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value]
}

View File

@@ -0,0 +1,110 @@
// =============================================================================
// PROCESSING PURPOSES (Step 3) — tile selection
// =============================================================================
export const PURPOSE_TILES = [
{ value: 'service_delivery', label: 'Serviceerbringung', icon: '⚙️', desc: 'Kernfunktion des Produkts oder Services' },
{ value: 'analytics', label: 'Analyse & BI', icon: '📊', desc: 'Statistische Auswertung, Business Intelligence, Reporting' },
{ value: 'marketing', label: 'Marketing & Werbung', icon: '📢', desc: 'Werbung, Personalisierung, Targeting, Newsletter' },
{ value: 'profiling', label: 'Profiling', icon: '🎯', desc: 'Automatisierte Analyse personenbezogener Aspekte' },
{ value: 'automated_decision', label: 'Automatisierte Entscheidung', icon: '🤖', desc: 'Art. 22 DSGVO — Entscheidung ohne menschliches Zutun' },
{ value: 'customer_support', label: 'Kundensupport', icon: '🎧', desc: 'Anfragenbearbeitung, Ticketsystem, Chatbot' },
{ value: 'quality_control', label: 'Qualitaetskontrolle', icon: '✅', desc: 'Produktpruefung, Fehleranalyse, Prozessoptimierung' },
{ value: 'hr_management', label: 'Personalverwaltung', icon: '👥', desc: 'Recruiting, Onboarding, Mitarbeiterentwicklung' },
{ value: 'fraud_detection', label: 'Betrugserkennung', icon: '🕵️', desc: 'Anomalieerkennung, Transaktionsueberwachung' },
{ value: 'research', label: 'Forschung & Entwicklung', icon: '🔬', desc: 'Wissenschaftliche Auswertung, Produktentwicklung' },
{ value: 'compliance_audit', label: 'Compliance & Audit', icon: '📜', desc: 'Regulatorische Pruefung, Dokumentation, Audit-Trail' },
{ value: 'communication', label: 'Kommunikation', icon: '💬', desc: 'Interne/externe Kommunikation, Uebersetzung' },
{ value: 'content_creation', label: 'Content-Erstellung', icon: '✍️', desc: 'Text-, Bild-, Video-Generierung' },
{ value: 'predictive', label: 'Vorhersage & Prognose', icon: '🔮', desc: 'Demand Forecasting, Predictive Analytics, Wartungsvorhersage' },
{ value: 'security', label: 'IT-Sicherheit', icon: '🛡️', desc: 'Bedrohungserkennung, Zugriffskontrolle, Monitoring' },
{ value: 'archiving', label: 'Archivierung', icon: '🗄️', desc: 'Gesetzliche Aufbewahrung, Dokumentenarchiv' },
]
// =============================================================================
// AUTOMATION LEVELS (Step 4) — single-select tiles
// =============================================================================
export const AUTOMATION_TILES = [
{ value: 'assistive', label: 'Assistiv (Mensch entscheidet)', icon: '🧑‍💻', desc: 'KI liefert Vorschlaege, Mensch trifft Entscheidung', examples: 'Rechtschreibkorrektur, Suchvorschlaege, Zusammenfassungen' },
{ value: 'semi_automated', label: 'Teilautomatisiert (Mensch prueft)', icon: '🤝', desc: 'KI erstellt Ergebnisse, Mensch prueft und bestaetigt', examples: 'E-Mail-Entwuerfe mit Freigabe, KI-Vertraege mit juristischer Pruefung' },
{ value: 'fully_automated', label: 'Vollautomatisiert (KI entscheidet)', icon: '🤖', desc: 'KI trifft Entscheidungen eigenstaendig', examples: 'Automatische Kreditentscheidungen, autonome Chatbot-Antworten' },
]
// =============================================================================
// HOSTING & MODEL (Step 5) — tiles
// =============================================================================
export const HOSTING_PROVIDER_TILES = [
{ value: 'self_hosted', label: 'Eigenes Hosting', icon: '🏢', desc: 'On-Premise oder eigene Server' },
{ value: 'hetzner', label: 'Hetzner (DE)', icon: '🇩🇪', desc: 'Deutsche Cloud-Infrastruktur' },
{ value: 'aws', label: 'AWS', icon: '☁️', desc: 'Amazon Web Services' },
{ value: 'azure', label: 'Microsoft Azure', icon: '🔷', desc: 'Microsoft Cloud' },
{ value: 'gcp', label: 'Google Cloud', icon: '🔵', desc: 'Google Cloud Platform' },
{ value: 'other', label: 'Anderer Anbieter', icon: '🌐', desc: 'Sonstiger Cloud-Anbieter' },
]
export const HOSTING_REGION_TILES = [
{ value: 'de', label: 'Deutschland', icon: '🇩🇪', desc: 'Rechenzentrum in Deutschland' },
{ value: 'eu', label: 'EU / EWR', icon: '🇪🇺', desc: 'Innerhalb der Europaeischen Union' },
{ value: 'us', label: 'USA', icon: '🇺🇸', desc: 'Vereinigte Staaten' },
{ value: 'other', label: 'Andere Region', icon: '🌍', desc: 'Drittland ausserhalb EU/USA' },
]
export const MODEL_USAGE_TILES = [
{ value: 'inference', label: 'Inferenz', icon: '⚡', desc: 'Fertiges Modell direkt nutzen (z.B. ChatGPT, Claude, DeepL)' },
{ value: 'rag', label: 'RAG', icon: '📚', desc: 'Modell erhaelt Kontext aus eigenen Dokumenten' },
{ value: 'finetune', label: 'Fine-Tuning', icon: '🎛️', desc: 'Bestehendes Modell mit eigenen Daten nachtrainieren' },
{ value: 'training', label: 'Eigenes Modell trainieren', icon: '🧠', desc: 'Komplett eigenes KI-Modell von Grund auf' },
]
// =============================================================================
// DATA TRANSFER (Step 6) — tiles
// =============================================================================
export const TRANSFER_TARGET_TILES = [
{ value: 'no_transfer', label: 'Kein Drittlandtransfer', icon: '🇪🇺', desc: 'Daten verbleiben in der EU/EWR' },
{ value: 'usa', label: 'USA', icon: '🇺🇸', desc: 'Datentransfer in die USA' },
{ value: 'uk', label: 'Grossbritannien', icon: '🇬🇧', desc: 'Datentransfer nach UK (Angemessenheitsbeschluss)' },
{ value: 'switzerland', label: 'Schweiz', icon: '🇨🇭', desc: 'Datentransfer in die Schweiz (Angemessenheitsbeschluss)' },
{ value: 'other_adequate', label: 'Anderes Land (Angemessenheit)', icon: '✅', desc: 'Land mit Angemessenheitsbeschluss der EU' },
{ value: 'other_third', label: 'Sonstiges Drittland', icon: '🌍', desc: 'Land ohne Angemessenheitsbeschluss' },
]
export const TRANSFER_MECHANISM_TILES = [
{ value: 'not_needed', label: 'Nicht erforderlich', icon: '✅', desc: 'Kein Drittlandtransfer oder Angemessenheit' },
{ value: 'scc', label: 'Standardvertragsklauseln', icon: '📝', desc: 'SCC nach Art. 46 Abs. 2c DSGVO' },
{ value: 'bcr', label: 'Binding Corporate Rules', icon: '🏛️', desc: 'BCR nach Art. 47 DSGVO' },
{ value: 'adequacy', label: 'Angemessenheitsbeschluss', icon: '🤝', desc: 'EU-Kommissionsbeschluss (z.B. EU-US DPF)' },
{ value: 'derogation', label: 'Ausnahme (Art. 49)', icon: '⚠️', desc: 'Einwilligung oder zwingende Interessen' },
]
// =============================================================================
// RETENTION (Step 7) — tiles
// =============================================================================
export const RETENTION_TILES = [
{ value: 'session', label: 'Nur waehrend Session', icon: '⏱️', desc: 'Daten werden nach Sitzungsende geloescht' },
{ value: '30_days', label: '30 Tage', icon: '📅', desc: 'Kurzfristige Aufbewahrung' },
{ value: '90_days', label: '90 Tage', icon: '📅', desc: 'Standardaufbewahrung' },
{ value: '1_year', label: '1 Jahr', icon: '📆', desc: 'Jaehrliche Aufbewahrung' },
{ value: '3_years', label: '3 Jahre', icon: '📆', desc: 'Mittelfristige Aufbewahrung' },
{ value: '6_years', label: '6 Jahre', icon: '📆', desc: 'Handelsrechtliche Aufbewahrungsfrist' },
{ value: '10_years', label: '10 Jahre', icon: '📆', desc: 'Steuerrechtliche Aufbewahrungsfrist' },
{ value: 'indefinite', label: 'Unbefristet', icon: '♾️', desc: 'Keine zeitliche Begrenzung' },
]
// =============================================================================
// CONTRACTS (Step 8) — tiles
// =============================================================================
export const CONTRACT_TILES = [
{ value: 'has_dpa', label: 'AVV / DPA vorhanden', icon: '📄', desc: 'Auftragsverarbeitungsvertrag nach Art. 28 DSGVO' },
{ value: 'has_aia_doc', label: 'AI Act Dokumentation', icon: '🤖', desc: 'Risikoklassifizierung und technische Doku nach EU AI Act' },
{ value: 'has_dsfa', label: 'DSFA durchgefuehrt', icon: '📋', desc: 'Datenschutz-Folgenabschaetzung nach Art. 35 DSGVO' },
{ value: 'has_tia', label: 'TIA durchgefuehrt', icon: '🌍', desc: 'Transfer Impact Assessment fuer Drittlandtransfers' },
{ value: 'has_tom', label: 'TOM dokumentiert', icon: '🔒', desc: 'Technisch-organisatorische Massnahmen nach Art. 32 DSGVO' },
{ value: 'has_vvt', label: 'Im VVT erfasst', icon: '📚', desc: 'Im Verzeichnis von Verarbeitungstaetigkeiten eingetragen' },
{ value: 'has_consent', label: 'Einwilligungen eingeholt', icon: '✅', desc: 'Nutzereinwilligungen vorhanden und dokumentiert' },
{ value: 'none', label: 'Noch keine Dokumente', icon: '⚠️', desc: 'Compliance-Dokumentation steht noch aus' },
]

View File

@@ -0,0 +1,25 @@
export interface AdvisoryForm {
title: string
use_case_text: string
domain: string
category: string
data_categories: string[]
custom_data_types: string[]
purposes: string[]
automation: string
hosting_provider: string
hosting_region: string
model_usage: string[]
transfer_targets: string[]
transfer_countries: string[]
transfer_mechanism: string
retention_period: string
retention_purpose: string
contracts: string[]
subprocessors: string
}
export interface StepProps {
form: AdvisoryForm
updateForm: (updates: Partial<AdvisoryForm>) => void
}

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

@@ -4,298 +4,19 @@ import React, { useState, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
// =============================================================================
// WIZARD STEPS CONFIG
// =============================================================================
const WIZARD_STEPS = [
{ id: 1, title: 'Grundlegendes', description: 'Titel, Beschreibung und KI-Kategorie' },
{ id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
{ id: 3, title: 'Verarbeitungszweck', description: 'Zweck der Datenverarbeitung' },
{ id: 4, title: 'Automatisierung', description: 'Grad der Automatisierung' },
{ id: 5, title: 'Hosting & Modell', description: 'Technische Details' },
{ id: 6, title: 'Datentransfer', description: 'Internationaler Datentransfer' },
{ id: 7, title: 'Datenhaltung', description: 'Aufbewahrung und Speicherung' },
{ id: 8, title: 'Vertraege', description: 'Compliance und Vereinbarungen' },
]
// =============================================================================
// KI-Anwendungskategorien als Auswahlkacheln
// =============================================================================
const AI_USE_CATEGORIES = [
{ value: 'content_generation', label: 'Content-Erstellung', icon: '✍️', desc: 'Texte, Berichte, E-Mails, Dokumentation automatisch erstellen' },
{ value: 'image_generation', label: 'Bilder erstellen', icon: '🎨', desc: 'KI-generierte Bilder, Grafiken, Produktfotos' },
{ value: 'marketing_material', label: 'Marketingmaterial', icon: '📢', desc: 'Werbetexte, Social Media Posts, Newsletter generieren' },
{ value: 'customer_service', label: 'Kundenservice / Chatbot', icon: '💬', desc: 'Automatisierte Kundenanfragen, FAQ-Bots, Support-Tickets' },
{ value: 'crm_analytics', label: 'CRM & Kundenanalyse', icon: '👥', desc: 'Kundensegmentierung, Churn-Vorhersage, Lead-Scoring' },
{ value: 'hr_recruiting', label: 'Bewerberauswahl / HR', icon: '🧑‍💼', desc: 'CV-Screening, Matching, Mitarbeiteranalysen' },
{ value: 'financial_analysis', label: 'Finanzdaten analysieren', icon: '📊', desc: 'Buchhaltung, Forecasting, Betrugs­erkennung, Risikobewertung' },
{ value: 'predictive_maintenance', label: 'Predictive Maintenance', icon: '🔧', desc: 'Vorausschauende Wartung, Ausfallvorhersage, IoT-Sensoranalyse' },
{ value: 'production_analytics', label: 'Produktionsdaten­auswertung', icon: '🏭', desc: 'Qualitaetskontrolle, Prozess­optimierung, OEE-Analyse' },
{ value: 'document_analysis', label: 'Dokumentenanalyse', icon: '📄', desc: 'Vertraege, Rechnungen, PDFs automatisch auswerten und klassifizieren' },
{ value: 'code_development', label: 'Softwareentwicklung', icon: '💻', desc: 'Code-Generierung, Code-Review, Test-Erstellung, Dokumentation' },
{ value: 'translation', label: 'Uebersetzung', icon: '🌍', desc: 'Automatische Uebersetzung von Texten, Dokumenten, Webinhalten' },
{ value: 'search_knowledge', label: 'Wissensmanagement / Suche', icon: '🔍', desc: 'Interne Wissensdatenbank, RAG-basierte Suche, FAQ-Systeme' },
{ value: 'data_extraction', label: 'Datenextraktion', icon: '⛏️', desc: 'OCR, Formularerkennung, strukturierte Daten aus Freitext' },
{ value: 'risk_compliance', label: 'Risiko & Compliance', icon: '⚖️', desc: 'Compliance-Pruefung, Risikobewertung, Audit-Unterstuetzung' },
{ value: 'supply_chain', label: 'Lieferkette & Logistik', icon: '🚛', desc: 'Bedarfsprognose, Routenoptimierung, Bestandsmanagement' },
{ value: 'medical_health', label: 'Medizin & Gesundheit', icon: '🏥', desc: 'Diagnoseunterstuetzung, Bildanalyse, Patientendaten' },
{ value: 'security_monitoring', label: 'Sicherheit & Monitoring', icon: '🛡️', desc: 'Anomalieerkennung, Bedrohungsanalyse, Zugriffskontrolle' },
{ value: 'personalization', label: 'Personalisierung', icon: '🎯', desc: 'Produktempfehlungen, dynamische Preisgestaltung, A/B-Testing' },
{ value: 'voice_speech', label: 'Sprache & Audio', icon: '🎙️', desc: 'Spracherkennung, Text-to-Speech, Meeting-Transkription' },
{ value: 'other', label: 'Sonstiges', icon: '', desc: 'Anderer KI-Anwendungsfall' },
]
// Map Profil-Branche to domain value for backend compatibility
function industryToDomain(industries: string[]): string {
if (!industries || industries.length === 0) return 'general'
const first = industries[0].toLowerCase()
if (first.includes('gesundheit') || first.includes('pharma')) return 'healthcare'
if (first.includes('finanz') || first.includes('versicherung')) return 'finance'
if (first.includes('bildung')) return 'education'
if (first.includes('handel') || first.includes('commerce')) return 'retail'
if (first.includes('it') || first.includes('technologie')) return 'it_services'
if (first.includes('beratung') || first.includes('consulting')) return 'consulting'
if (first.includes('produktion') || first.includes('industrie') || first.includes('maschinenbau')) return 'manufacturing'
if (first.includes('marketing') || first.includes('agentur')) return 'marketing'
if (first.includes('recht')) return 'legal'
return 'general'
}
// =============================================================================
// DATA CATEGORIES (Step 2) — grouped tile selection
// =============================================================================
const DATA_CATEGORY_GROUPS = [
{
group: 'Stamm- & Kontaktdaten',
items: [
{ value: 'basic_identity', label: 'Name & Identitaet', icon: '👤', desc: 'Vor-/Nachname, Geburtsdatum, Geschlecht' },
{ value: 'contact_data', label: 'Kontaktdaten', icon: '📧', desc: 'E-Mail, Telefon, Fax' },
{ value: 'address_data', label: 'Adressdaten', icon: '🏠', desc: 'Wohn-/Meldeadresse, PLZ, Lieferadresse' },
{ value: 'government_ids', label: 'Ausweisdaten', icon: '🪪', desc: 'Personalausweis-Nr., Reisepass, Fuehrerschein' },
{ value: 'customer_ids', label: 'Kundennummern', icon: '🏷️', desc: 'Kunden-ID, Vertrags-Nr., Mitgliedsnummer' },
],
},
{
group: 'Besondere Kategorien (Art. 9 DSGVO)',
art9: true,
items: [
{ value: 'health_data', label: 'Gesundheitsdaten', icon: '🏥', desc: 'Diagnosen, Medikation, AU, Pflegegrad' },
{ value: 'biometric_data', label: 'Biometrische Daten', icon: '🔐', desc: 'Fingerabdruck, Gesichtserkennung, Iris-Scan' },
{ value: 'genetic_data', label: 'Genetische Daten', icon: '🧬', desc: 'DNA-Profil, Genomsequenzen, Erbkrankheitstests' },
{ value: 'racial_ethnic', label: 'Ethnische Herkunft', icon: '🌍', desc: 'Rassische/ethnische Zugehoerigkeit' },
{ value: 'political_opinions', label: 'Politische Meinungen', icon: '🗳️', desc: 'Politische Ueberzeugungen, Parteizugehoerigkeit' },
{ value: 'religious_beliefs', label: 'Religion', icon: '🕊️', desc: 'Religionszugehoerigkeit, Weltanschauung' },
{ value: 'trade_union', label: 'Gewerkschaft', icon: '🤝', desc: 'Gewerkschaftsmitgliedschaft' },
{ value: 'sexual_orientation', label: 'Sexuelle Orientierung', icon: '🏳️‍🌈', desc: 'Sexualleben und Orientierung' },
],
},
{
group: 'Finanz- & Steuerdaten',
items: [
{ value: 'bank_account', label: 'Bankverbindung', icon: '🏦', desc: 'IBAN, BIC, Kontonummer' },
{ value: 'payment_card', label: 'Zahlungskarten', icon: '💳', desc: 'Kreditkarten-Nr., CVV (PCI-DSS)' },
{ value: 'transaction_data', label: 'Transaktionsdaten', icon: '🧾', desc: 'Zahlungshistorie, Ueberweisungen, Kaufhistorie' },
{ value: 'credit_score', label: 'Bonitaet / Schufa', icon: '📈', desc: 'Kreditwuerdigkeit, Schuldenhistorie' },
{ value: 'income_salary', label: 'Einkommen & Gehalt', icon: '💰', desc: 'Bruttogehalt, Nettolohn, Boni' },
{ value: 'tax_ids', label: 'Steuer-IDs', icon: '📋', desc: 'Steuer-ID, Steuernummer, USt-IdNr.' },
{ value: 'insurance_data', label: 'Versicherungsdaten', icon: '☂️', desc: 'Versicherungsnummern, Policen, Schadenmeldungen' },
],
},
{
group: 'Fahrzeug- & Mobilitaetsdaten',
items: [
{ value: 'vehicle_ids', label: 'Fahrzeug-IDs (VIN)', icon: '🚗', desc: 'Fahrgestellnummer (VIN/FIN), Fahrzeugschein' },
{ value: 'license_plates', label: 'Kennzeichen', icon: '🔢', desc: 'Amtliches Kennzeichen, Wunschkennzeichen' },
{ value: 'gps_tracking', label: 'GPS & Routen', icon: '📍', desc: 'Echtzeitposition, Fahrtenprotokolle' },
{ value: 'telematics', label: 'Telematikdaten', icon: '📡', desc: 'Fahrverhalten, Geschwindigkeit, Motordiagnose' },
{ value: 'fleet_data', label: 'Fuhrpark / Logistik', icon: '🚛', desc: 'Einsatzzeiten, Kilometerstand, Fahrerzuweisung' },
],
},
{
group: 'Technische Identifikatoren',
items: [
{ value: 'ip_address', label: 'IP-Adresse', icon: '🌐', desc: 'IPv4/IPv6 (EuGH: personenbezogen)' },
{ value: 'device_ids', label: 'Geraete-IDs', icon: '📱', desc: 'IMEI, UUID, Advertising-ID, Seriennummer' },
{ value: 'cookies_tracking', label: 'Cookies & Tracking', icon: '🍪', desc: 'Session-/Persistent Cookies, Pixel-Tags' },
{ value: 'browser_fingerprint', label: 'Browser-Fingerprint', icon: '🔎', desc: 'Browser-Typ, OS, Plugins, Canvas-Fingerprint' },
{ value: 'mac_address', label: 'MAC-Adresse', icon: '📶', desc: 'Netzwerkadapter-Kennung, WLAN-Praesenz' },
],
},
{
group: 'Verhaltens- & Nutzungsdaten',
items: [
{ value: 'clickstream', label: 'Klick- & Nutzungspfade', icon: '🖱️', desc: 'Klickpfade, Scrolltiefe, Verweildauer, Heatmaps' },
{ value: 'purchase_history', label: 'Kaufverhalten', icon: '🛒', desc: 'Bestellhistorie, Warenkorb, Wunschlisten' },
{ value: 'app_usage', label: 'App-Nutzung', icon: '📲', desc: 'Genutzte Apps, Nutzungsdauer, In-App-Aktivitaeten' },
{ value: 'profiling_scores', label: 'Profiling / Scoring', icon: '📊', desc: 'KI-generierte Profile, Segmente, Affinitaetsscores' },
],
},
{
group: 'Kommunikation & Medien',
items: [
{ value: 'email_content', label: 'E-Mail-Inhalte', icon: '✉️', desc: 'E-Mail-Texte, Anhaenge, Metadaten' },
{ value: 'chat_messages', label: 'Chat & Messaging', icon: '💬', desc: 'Textnachrichten, Messenger, Teams, Slack' },
{ value: 'call_recordings', label: 'Telefonaufzeichnungen', icon: '📞', desc: 'Gespraeche, Transkripte, Anrufmetadaten' },
{ value: 'video_conference', label: 'Videokonferenzen', icon: '📹', desc: 'Meeting-Aufzeichnungen, Teilnehmerlisten' },
{ value: 'photographs', label: 'Fotos & Bilder', icon: '📷', desc: 'Portraitfotos, Profilbilder, Produktfotos' },
{ value: 'cctv_surveillance', label: 'Videoueberwachung', icon: '📹', desc: 'CCTV-Aufnahmen, Zutrittskontrolle' },
{ value: 'voice_recordings', label: 'Sprachaufnahmen', icon: '🎙️', desc: 'Voicemails, Sprachmemos, Diktate' },
],
},
{
group: 'HR & Beschaeftigung',
items: [
{ value: 'employment_data', label: 'Beschaeftigungsdaten', icon: '💼', desc: 'Arbeitgeber, Berufsbezeichnung, Vertragsart' },
{ value: 'performance_data', label: 'Leistungsbeurteilungen', icon: '🏆', desc: 'Zielerreichung, Feedback, Abmahnungen' },
{ value: 'work_time', label: 'Arbeitszeit', icon: '⏰', desc: 'Zeiterfassung, Ueberstunden, Schichtplaene' },
{ value: 'candidate_data', label: 'Bewerberdaten', icon: '📝', desc: 'Lebenslaeufe, Interviews, Assessment-Ergebnisse' },
{ value: 'social_security', label: 'Sozialversicherungs-Nr.', icon: '🛡️', desc: 'RVNR (Art. 9 — kodiert Geburtsdatum/Geschlecht)' },
],
},
{
group: 'IoT & Sensordaten',
items: [
{ value: 'industrial_sensor', label: 'Industriesensoren', icon: '🏭', desc: 'Maschinendaten, Fehlerprotokolle, Produktionsmesswerte' },
{ value: 'wearable_data', label: 'Wearable-Daten', icon: '⌚', desc: 'Herzfrequenz, Schritte, Schlaf (Art. 9 — Gesundheit)' },
{ value: 'smart_home', label: 'Smart-Home', icon: '🏡', desc: 'Heizung, Licht, Bewegungsmelder, Nutzungszeiten' },
{ value: 'energy_data', label: 'Energieverbrauch', icon: '🔌', desc: 'Smart-Meter, Verbrauchsprofil (enthuellt Verhalten)' },
],
},
{
group: 'Sonstige Kategorien',
items: [
{ value: 'children_data', label: 'Kinderdaten (unter 16)', icon: '👶', desc: 'Besonderer Schutz, Eltern-Einwilligung erforderlich' },
{ value: 'criminal_data', label: 'Strafrechtliche Daten', icon: '⚖️', desc: 'Vorstrafen, Ermittlungsverfahren (Art. 10 DSGVO)' },
{ value: 'location_data', label: 'Standortdaten', icon: '📍', desc: 'GPS, Mobilfunk, WLAN-Ortung, Bewegungsprofile' },
{ value: 'social_media', label: 'Social-Media-Daten', icon: '📱', desc: 'Profile, Posts, Follower, Interaktionen' },
{ value: 'auth_credentials', label: 'Login & Zugangsdaten', icon: '🔑', desc: 'Passwoerter, 2FA, Session-Tokens, Zugriffsprotokolle' },
],
},
]
// =============================================================================
// PROCESSING PURPOSES (Step 3) — tile selection
// =============================================================================
const PURPOSE_TILES = [
{ value: 'service_delivery', label: 'Serviceerbringung', icon: '⚙️', desc: 'Kernfunktion des Produkts oder Services' },
{ value: 'analytics', label: 'Analyse & BI', icon: '📊', desc: 'Statistische Auswertung, Business Intelligence, Reporting' },
{ value: 'marketing', label: 'Marketing & Werbung', icon: '📢', desc: 'Werbung, Personalisierung, Targeting, Newsletter' },
{ value: 'profiling', label: 'Profiling', icon: '🎯', desc: 'Automatisierte Analyse personenbezogener Aspekte' },
{ value: 'automated_decision', label: 'Automatisierte Entscheidung', icon: '🤖', desc: 'Art. 22 DSGVO — Entscheidung ohne menschliches Zutun' },
{ value: 'customer_support', label: 'Kundensupport', icon: '🎧', desc: 'Anfragenbearbeitung, Ticketsystem, Chatbot' },
{ value: 'quality_control', label: 'Qualitaetskontrolle', icon: '✅', desc: 'Produktpruefung, Fehleranalyse, Prozessoptimierung' },
{ value: 'hr_management', label: 'Personalverwaltung', icon: '👥', desc: 'Recruiting, Onboarding, Mitarbeiterentwicklung' },
{ value: 'fraud_detection', label: 'Betrugserkennung', icon: '🕵️', desc: 'Anomalieerkennung, Transaktionsueberwachung' },
{ value: 'research', label: 'Forschung & Entwicklung', icon: '🔬', desc: 'Wissenschaftliche Auswertung, Produktentwicklung' },
{ value: 'compliance_audit', label: 'Compliance & Audit', icon: '📜', desc: 'Regulatorische Pruefung, Dokumentation, Audit-Trail' },
{ value: 'communication', label: 'Kommunikation', icon: '💬', desc: 'Interne/externe Kommunikation, Uebersetzung' },
{ value: 'content_creation', label: 'Content-Erstellung', icon: '✍️', desc: 'Text-, Bild-, Video-Generierung' },
{ value: 'predictive', label: 'Vorhersage & Prognose', icon: '🔮', desc: 'Demand Forecasting, Predictive Analytics, Wartungsvorhersage' },
{ value: 'security', label: 'IT-Sicherheit', icon: '🛡️', desc: 'Bedrohungserkennung, Zugriffskontrolle, Monitoring' },
{ value: 'archiving', label: 'Archivierung', icon: '🗄️', desc: 'Gesetzliche Aufbewahrung, Dokumentenarchiv' },
]
// =============================================================================
// AUTOMATION LEVELS (Step 4) — single-select tiles
// =============================================================================
const AUTOMATION_TILES = [
{ value: 'assistive', label: 'Assistiv (Mensch entscheidet)', icon: '🧑‍💻', desc: 'KI liefert Vorschlaege, Mensch trifft Entscheidung', examples: 'Rechtschreibkorrektur, Suchvorschlaege, Zusammenfassungen' },
{ value: 'semi_automated', label: 'Teilautomatisiert (Mensch prueft)', icon: '🤝', desc: 'KI erstellt Ergebnisse, Mensch prueft und bestaetigt', examples: 'E-Mail-Entwuerfe mit Freigabe, KI-Vertraege mit juristischer Pruefung' },
{ value: 'fully_automated', label: 'Vollautomatisiert (KI entscheidet)', icon: '🤖', desc: 'KI trifft Entscheidungen eigenstaendig', examples: 'Automatische Kreditentscheidungen, autonome Chatbot-Antworten' },
]
// =============================================================================
// HOSTING & MODEL (Step 5) — tiles
// =============================================================================
const HOSTING_PROVIDER_TILES = [
{ value: 'self_hosted', label: 'Eigenes Hosting', icon: '🏢', desc: 'On-Premise oder eigene Server' },
{ value: 'hetzner', label: 'Hetzner (DE)', icon: '🇩🇪', desc: 'Deutsche Cloud-Infrastruktur' },
{ value: 'aws', label: 'AWS', icon: '☁️', desc: 'Amazon Web Services' },
{ value: 'azure', label: 'Microsoft Azure', icon: '🔷', desc: 'Microsoft Cloud' },
{ value: 'gcp', label: 'Google Cloud', icon: '🔵', desc: 'Google Cloud Platform' },
{ value: 'other', label: 'Anderer Anbieter', icon: '🌐', desc: 'Sonstiger Cloud-Anbieter' },
]
const HOSTING_REGION_TILES = [
{ value: 'de', label: 'Deutschland', icon: '🇩🇪', desc: 'Rechenzentrum in Deutschland' },
{ value: 'eu', label: 'EU / EWR', icon: '🇪🇺', desc: 'Innerhalb der Europaeischen Union' },
{ value: 'us', label: 'USA', icon: '🇺🇸', desc: 'Vereinigte Staaten' },
{ value: 'other', label: 'Andere Region', icon: '🌍', desc: 'Drittland ausserhalb EU/USA' },
]
const MODEL_USAGE_TILES = [
{ value: 'inference', label: 'Inferenz', icon: '⚡', desc: 'Fertiges Modell direkt nutzen (z.B. ChatGPT, Claude, DeepL)' },
{ value: 'rag', label: 'RAG', icon: '📚', desc: 'Modell erhaelt Kontext aus eigenen Dokumenten' },
{ value: 'finetune', label: 'Fine-Tuning', icon: '🎛️', desc: 'Bestehendes Modell mit eigenen Daten nachtrainieren' },
{ value: 'training', label: 'Eigenes Modell trainieren', icon: '🧠', desc: 'Komplett eigenes KI-Modell von Grund auf' },
]
// =============================================================================
// DATA TRANSFER (Step 6) — tiles
// =============================================================================
const TRANSFER_TARGET_TILES = [
{ value: 'no_transfer', label: 'Kein Drittlandtransfer', icon: '🇪🇺', desc: 'Daten verbleiben in der EU/EWR' },
{ value: 'usa', label: 'USA', icon: '🇺🇸', desc: 'Datentransfer in die USA' },
{ value: 'uk', label: 'Grossbritannien', icon: '🇬🇧', desc: 'Datentransfer nach UK (Angemessenheitsbeschluss)' },
{ value: 'switzerland', label: 'Schweiz', icon: '🇨🇭', desc: 'Datentransfer in die Schweiz (Angemessenheitsbeschluss)' },
{ value: 'other_adequate', label: 'Anderes Land (Angemessenheit)', icon: '✅', desc: 'Land mit Angemessenheitsbeschluss der EU' },
{ value: 'other_third', label: 'Sonstiges Drittland', icon: '🌍', desc: 'Land ohne Angemessenheitsbeschluss' },
]
const TRANSFER_MECHANISM_TILES = [
{ value: 'not_needed', label: 'Nicht erforderlich', icon: '✅', desc: 'Kein Drittlandtransfer oder Angemessenheit' },
{ value: 'scc', label: 'Standardvertragsklauseln', icon: '📝', desc: 'SCC nach Art. 46 Abs. 2c DSGVO' },
{ value: 'bcr', label: 'Binding Corporate Rules', icon: '🏛️', desc: 'BCR nach Art. 47 DSGVO' },
{ value: 'adequacy', label: 'Angemessenheitsbeschluss', icon: '🤝', desc: 'EU-Kommissionsbeschluss (z.B. EU-US DPF)' },
{ value: 'derogation', label: 'Ausnahme (Art. 49)', icon: '⚠️', desc: 'Einwilligung oder zwingende Interessen' },
]
// =============================================================================
// RETENTION (Step 7) — tiles
// =============================================================================
const RETENTION_TILES = [
{ value: 'session', label: 'Nur waehrend Session', icon: '⏱️', desc: 'Daten werden nach Sitzungsende geloescht' },
{ value: '30_days', label: '30 Tage', icon: '📅', desc: 'Kurzfristige Aufbewahrung' },
{ value: '90_days', label: '90 Tage', icon: '📅', desc: 'Standardaufbewahrung' },
{ value: '1_year', label: '1 Jahr', icon: '📆', desc: 'Jaehrliche Aufbewahrung' },
{ value: '3_years', label: '3 Jahre', icon: '📆', desc: 'Mittelfristige Aufbewahrung' },
{ value: '6_years', label: '6 Jahre', icon: '📆', desc: 'Handelsrechtliche Aufbewahrungsfrist' },
{ value: '10_years', label: '10 Jahre', icon: '📆', desc: 'Steuerrechtliche Aufbewahrungsfrist' },
{ value: 'indefinite', label: 'Unbefristet', icon: '♾️', desc: 'Keine zeitliche Begrenzung' },
]
// =============================================================================
// CONTRACTS (Step 8) — tiles
// =============================================================================
const CONTRACT_TILES = [
{ value: 'has_dpa', label: 'AVV / DPA vorhanden', icon: '📄', desc: 'Auftragsverarbeitungsvertrag nach Art. 28 DSGVO' },
{ value: 'has_aia_doc', label: 'AI Act Dokumentation', icon: '🤖', desc: 'Risikoklassifizierung und technische Doku nach EU AI Act' },
{ value: 'has_dsfa', label: 'DSFA durchgefuehrt', icon: '📋', desc: 'Datenschutz-Folgenabschaetzung nach Art. 35 DSGVO' },
{ value: 'has_tia', label: 'TIA durchgefuehrt', icon: '🌍', desc: 'Transfer Impact Assessment fuer Drittlandtransfers' },
{ value: 'has_tom', label: 'TOM dokumentiert', icon: '🔒', desc: 'Technisch-organisatorische Massnahmen nach Art. 32 DSGVO' },
{ value: 'has_vvt', label: 'Im VVT erfasst', icon: '📚', desc: 'Im Verzeichnis von Verarbeitungstaetigkeiten eingetragen' },
{ value: 'has_consent', label: 'Einwilligungen eingeholt', icon: '✅', desc: 'Nutzereinwilligungen vorhanden und dokumentiert' },
{ value: 'none', label: 'Noch keine Dokumente', icon: '⚠️', desc: 'Compliance-Dokumentation steht noch aus' },
]
// =============================================================================
// SHARED TILE TOGGLE HELPER
// =============================================================================
function toggleInArray(arr: string[], value: string): string[] {
return arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value]
}
import type { AdvisoryForm } from './_types'
import { industryToDomain } from './_data'
import { StepIndicator } from './_components/StepIndicator'
import { Step1Basics } from './_components/Step1Basics'
import { Step2DataCategories } from './_components/Step2DataCategories'
import { Step3Purposes } from './_components/Step3Purposes'
import { Step4Automation } from './_components/Step4Automation'
import { Step5Hosting } from './_components/Step5Hosting'
import { Step6Transfer } from './_components/Step6Transfer'
import { Step7Retention } from './_components/Step7Retention'
import { Step8Contracts } from './_components/Step8Contracts'
import { NavigationButtons } from './_components/NavigationButtons'
import { ResultView } from './_components/ResultView'
// =============================================================================
// MAIN COMPONENT
@@ -321,36 +42,28 @@ function AdvisoryBoardPageInner() {
)
// Form state — tile-based multi-select via arrays
const [form, setForm] = useState({
const [form, setForm] = useState<AdvisoryForm>({
title: '',
use_case_text: '',
domain: 'general',
category: '' as string,
// Data categories (multi-select tiles)
data_categories: [] as string[],
custom_data_types: [] as string[],
// Purpose (multi-select tiles)
purposes: [] as string[],
// Automation (single-select tile)
automation: '' as string,
// Hosting (single-select tile)
hosting_provider: '' as string,
hosting_region: '' as string,
// Model Usage (multi-select tiles)
model_usage: [] as string[],
// Data Transfer (Step 6 — tiles)
transfer_targets: [] as string[],
transfer_countries: [] as string[],
transfer_mechanism: '' as string,
// Retention (Step 7)
retention_period: '' as string,
category: '',
data_categories: [],
custom_data_types: [],
purposes: [],
automation: '',
hosting_provider: '',
hosting_region: '',
model_usage: [],
transfer_targets: [],
transfer_countries: [],
transfer_mechanism: '',
retention_period: '',
retention_purpose: '',
// Contracts (Step 8 — multi-select tiles)
contracts: [] as string[],
contracts: [],
subprocessors: '',
})
const updateForm = (updates: Partial<typeof form>) => {
const updateForm = (updates: Partial<AdvisoryForm>) => {
setForm(prev => ({ ...prev, ...updates }))
}
@@ -455,32 +168,12 @@ function AdvisoryBoardPageInner() {
// If we have a result, show it
if (result) {
const r = result as { assessment?: { id: string }; result?: Record<string, unknown> }
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Assessment Ergebnis</h1>
<div className="flex gap-2">
{r.assessment?.id && (
<button
onClick={() => router.push(`/sdk/use-cases/${r.assessment!.id}`)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Zum Assessment
</button>
)}
<button
onClick={() => router.push('/sdk/use-cases')}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
Zur Uebersicht
</button>
</div>
</div>
{r.result && (
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
)}
</div>
<ResultView
result={result}
onGoToAssessment={(id) => router.push(`/sdk/use-cases/${id}`)}
onGoToOverview={() => router.push('/sdk/use-cases')}
/>
)
}
@@ -513,29 +206,7 @@ function AdvisoryBoardPageInner() {
</div>
)}
{/* Step Indicator */}
<div className="flex items-center gap-2">
{WIZARD_STEPS.map((step, idx) => (
<React.Fragment key={step.id}>
<button
onClick={() => setCurrentStep(step.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
currentStep === step.id
? 'bg-purple-600 text-white'
: currentStep > step.id
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}
>
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
{currentStep > step.id ? '✓' : step.id}
</span>
<span className="hidden md:inline">{step.title}</span>
</button>
{idx < WIZARD_STEPS.length - 1 && <div className="flex-1 h-px bg-gray-200" />}
</React.Fragment>
))}
</div>
<StepIndicator currentStep={currentStep} onStepClick={setCurrentStep} />
{/* Error */}
{error && (
@@ -544,545 +215,27 @@ function AdvisoryBoardPageInner() {
{/* Step Content */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
{/* Step 1: Grundlegendes */}
{currentStep === 1 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Grundlegende Informationen</h2>
{/* Branche aus Profil (nur Anzeige) */}
{profileIndustry && (Array.isArray(profileIndustry) ? profileIndustry.length > 0 : true) && (
<div className="bg-gray-50 rounded-lg border border-gray-200 px-4 py-3">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Branche (aus Unternehmensprofil)</span>
<p className="text-sm text-gray-900 mt-0.5">
{Array.isArray(profileIndustry) ? profileIndustry.join(', ') : profileIndustry}
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel des Anwendungsfalls</label>
<input
type="text"
value={form.title}
onChange={e => updateForm({ title: e.target.value })}
placeholder="z.B. Chatbot fuer Kundenservice"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={form.use_case_text}
onChange={e => updateForm({ use_case_text: e.target.value })}
rows={4}
placeholder="Beschreiben Sie den Anwendungsfall..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{/* KI-Anwendungskategorie als Kacheln */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
In welchem Bereich kommt KI zum Einsatz?
</label>
<p className="text-sm text-gray-500 mb-3">Waehlen Sie die passende Kategorie fuer Ihren Anwendungsfall.</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{AI_USE_CATEGORIES.map(cat => (
<button
key={cat.value}
type="button"
onClick={() => updateForm({ category: cat.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.category === cat.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{cat.icon}</span>
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{cat.desc}</p>
</button>
))}
</div>
</div>
</div>
)}
{/* Step 2: Datenkategorien */}
{currentStep === 2 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Welche Daten werden verarbeitet?</h2>
<p className="text-sm text-gray-500">Waehlen Sie alle Datenkategorien, die in diesem Use Case verarbeitet werden.</p>
{DATA_CATEGORY_GROUPS.map(group => (
<div key={group.group}>
<h3 className={`text-sm font-semibold mb-2 ${group.art9 ? 'text-orange-700' : 'text-gray-700'}`}>
{group.art9 && '⚠️ '}{group.group}
</h3>
{group.art9 && (
<p className="text-xs text-orange-600 mb-2">Besonders schutzwuerdig erhoehte Anforderungen an Rechtsgrundlage und TOM</p>
)}
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-4">
{group.items.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ data_categories: toggleInArray(form.data_categories, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.data_categories.includes(item.value)
? group.art9
? 'border-orange-500 bg-orange-50 ring-1 ring-orange-300'
: 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
))}
{/* Sonstige Datentypen */}
<div className="border border-gray-200 rounded-lg p-4 space-y-3">
<div className="font-medium text-gray-900">Sonstige Datentypen</div>
<p className="text-sm text-gray-500">
Falls Ihre Datenkategorie oben nicht aufgefuehrt ist, koennen Sie sie hier ergaenzen.
</p>
{form.custom_data_types.map((dt, idx) => (
<div key={idx} className="flex items-center gap-2">
<input
type="text"
value={dt}
onChange={e => {
const updated = [...form.custom_data_types]
updated[idx] = e.target.value
updateForm({ custom_data_types: updated })
}}
placeholder="Datentyp eingeben..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<button
onClick={() => updateForm({ custom_data_types: form.custom_data_types.filter((_, i) => i !== idx) })}
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
title="Entfernen"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
))}
<button
onClick={() => updateForm({ custom_data_types: [...form.custom_data_types, ''] })}
className="flex items-center gap-1 text-sm text-purple-600 hover:text-purple-700 font-medium"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
Weiteren Datentyp hinzufuegen
</button>
</div>
{form.data_categories.length > 0 && (
<div className="bg-purple-50 border border-purple-200 rounded-lg px-4 py-3 text-sm text-purple-800">
<span className="font-medium">{form.data_categories.length}</span> Datenkategorie{form.data_categories.length !== 1 ? 'n' : ''} ausgewaehlt
{form.data_categories.some(c => DATA_CATEGORY_GROUPS.find(g => g.art9)?.items.some(i => i.value === c)) && (
<span className="ml-2 text-orange-700 font-medium"> inkl. besonderer Kategorien (Art. 9)</span>
)}
</div>
)}
</div>
)}
{/* Step 3: Verarbeitungszweck */}
{currentStep === 3 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Zweck der Verarbeitung</h2>
<p className="text-sm text-gray-500">Waehlen Sie alle zutreffenden Verarbeitungszwecke. Die passende Rechtsgrundlage wird vom SDK automatisch ermittelt.</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{PURPOSE_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ purposes: toggleInArray(form.purposes, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.purposes.includes(item.value)
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
{form.purposes.includes('profiling') && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
<div className="font-medium mb-1">Hinweis: Profiling</div>
<p>Profiling unterliegt besonderen Anforderungen nach Art. 22 DSGVO. Betroffene haben das Recht auf Information und Widerspruch.</p>
</div>
)}
{form.purposes.includes('automated_decision') && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">
<div className="font-medium mb-1">Achtung: Automatisierte Entscheidung</div>
<p>Art. 22 DSGVO: Vollautomatisierte Entscheidungen mit rechtlicher Wirkung erfordern besondere Schutzmassnahmen, Informationspflichten und das Recht auf menschliche Ueberpruefung.</p>
</div>
)}
</div>
)}
{/* Step 4: Automatisierung */}
{currentStep === 4 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2>
<p className="text-sm text-gray-600">
Wie stark greift die KI in Entscheidungen ein? Je hoeher der Automatisierungsgrad, desto strenger die regulatorischen Anforderungen.
</p>
<div className="grid grid-cols-1 gap-3">
{AUTOMATION_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ automation: item.value })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
form.automation === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3 mb-1">
<span className="text-2xl">{item.icon}</span>
<span className="text-sm font-semibold text-gray-900">{item.label}</span>
</div>
<p className="text-sm text-gray-500 ml-11">{item.desc}</p>
<p className="text-xs text-gray-400 ml-11 mt-1">Beispiele: {item.examples}</p>
</button>
))}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<div className="font-medium mb-1">Warum ist das wichtig?</div>
<p>
Art. 22 DSGVO regelt automatisierte Einzelentscheidungen. Vollautomatisierte Systeme, die Personen
erheblich beeinflussen (z.B. Kreditvergabe, Bewerbungsauswahl), unterliegen strengen Auflagen:
Informationspflicht, Recht auf menschliche Ueberpruefung und Anfechtungsmoeglichkeit.
</p>
</div>
</div>
)}
{/* Step 5: Hosting & Modell */}
{currentStep === 5 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Technische Details</h2>
{/* Hosting Provider */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Hosting-Anbieter</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{HOSTING_PROVIDER_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ hosting_provider: item.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.hosting_provider === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
{/* Hosting Region */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Hosting-Region</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{HOSTING_REGION_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ hosting_region: item.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.hosting_region === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
{/* Model Usage */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-1">Wie wird das KI-Modell genutzt?</h3>
<p className="text-sm text-gray-500 mb-3">Waehlen Sie alle zutreffenden Optionen.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{MODEL_USAGE_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ model_usage: toggleInArray(form.model_usage, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.model_usage.includes(item.value)
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
{/* Info-Box: Begriffe erklaert */}
<details className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
<summary className="px-4 py-3 text-sm font-medium text-amber-800 cursor-pointer hover:bg-amber-100">
Begriffe erklaert: ML, DL, NLP, LLM &mdash; Was bedeutet das?
</summary>
<div className="px-4 pb-4 space-y-3 text-sm text-amber-900">
<div><span className="font-semibold">ML (Machine Learning)</span> &mdash; Computer lernt Muster aus Daten. Beispiel: Spam-Filter.</div>
<div><span className="font-semibold">DL (Deep Learning)</span> &mdash; ML mit neuronalen Netzen. Beispiel: Bilderkennung, Spracherkennung.</div>
<div><span className="font-semibold">NLP (Natural Language Processing)</span> &mdash; KI versteht Sprache. Beispiel: ChatGPT, DeepL.</div>
<div><span className="font-semibold">LLM (Large Language Model)</span> &mdash; Grosses Sprachmodell. Beispiel: GPT-4, Claude, Llama.</div>
<div><span className="font-semibold">RAG</span> &mdash; LLM erhaelt Kontext aus eigener Datenbank. Vorteil: Aktuelle, firmenspezifische Antworten.</div>
<div><span className="font-semibold">Fine-Tuning</span> &mdash; Bestehendes Modell mit eigenen Daten weitertrainieren. Achtung: Daten werden Teil des Modells.</div>
</div>
</details>
</div>
)}
{/* Step 6: Internationaler Datentransfer */}
{currentStep === 6 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Internationaler Datentransfer</h2>
<p className="text-sm text-gray-500">Wohin werden die Daten uebermittelt? Waehlen Sie alle zutreffenden Ziellaender/-regionen.</p>
{/* Transfer Targets */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Datentransfer-Ziele</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{TRANSFER_TARGET_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ transfer_targets: toggleInArray(form.transfer_targets, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.transfer_targets.includes(item.value)
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
{/* Transfer Mechanism — only if not "no_transfer" only */}
{form.transfer_targets.length > 0 && !form.transfer_targets.every(t => t === 'no_transfer') && (
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Transfer-Mechanismus</h3>
<p className="text-sm text-gray-500 mb-3">Welche Schutzgarantie nutzen Sie fuer den Drittlandtransfer?</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TRANSFER_MECHANISM_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ transfer_mechanism: item.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.transfer_mechanism === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
</div>
)}
{/* Specific countries text input */}
{form.transfer_targets.some(t => !['no_transfer'].includes(t)) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Konkrete Ziellaender (optional)</label>
<input
type="text"
value={form.transfer_countries.join(', ')}
onChange={e => updateForm({ transfer_countries: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
placeholder="z.B. USA, UK, Schweiz, Japan"
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<p className="text-xs text-gray-500 mt-1">Kommagetrennte Laendernamen oder -kuerzel</p>
</div>
)}
</div>
)}
{/* Step 7: Datenhaltung */}
{currentStep === 7 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2>
<p className="text-sm text-gray-500">Wie lange sollen die Daten gespeichert werden?</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{RETENTION_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ retention_period: item.value })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.retention_period === item.value
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Zweck der Aufbewahrung (optional)
</label>
<textarea
value={form.retention_purpose}
onChange={e => updateForm({ retention_purpose: e.target.value })}
rows={2}
placeholder="z.B. Vertragliche Pflichten, gesetzliche Aufbewahrungsfristen..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{form.retention_period === 'indefinite' && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
<div className="font-medium mb-1">Hinweis: Unbefristete Speicherung</div>
<p>Die DSGVO fordert Datenminimierung und Speicherbegrenzung (Art. 5 Abs. 1e). Unbefristete Speicherung muss besonders gut begruendet sein.</p>
</div>
)}
</div>
)}
{/* Step 8: Vertraege & Compliance */}
{currentStep === 8 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Vertraege & Compliance-Dokumentation</h2>
<p className="text-sm text-gray-500">Welche Compliance-Dokumente liegen bereits vor? (Mehrfachauswahl moeglich)</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{CONTRACT_TILES.map(item => (
<button
key={item.value}
type="button"
onClick={() => updateForm({ contracts: toggleInArray(form.contracts, item.value) })}
className={`p-3 rounded-xl border-2 text-left transition-all ${
form.contracts.includes(item.value)
? item.value === 'none'
? 'border-amber-500 bg-amber-50 ring-1 ring-amber-300'
: 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
</button>
))}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Subprozessoren (optional)</label>
<textarea
value={form.subprocessors}
onChange={e => updateForm({ subprocessors: e.target.value })}
rows={2}
placeholder="z.B. OpenAI (USA, SCC), Hetzner Cloud (DE)..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
<Step1Basics form={form} updateForm={updateForm} profileIndustry={profileIndustry} />
)}
{currentStep === 2 && <Step2DataCategories form={form} updateForm={updateForm} />}
{currentStep === 3 && <Step3Purposes form={form} updateForm={updateForm} />}
{currentStep === 4 && <Step4Automation form={form} updateForm={updateForm} />}
{currentStep === 5 && <Step5Hosting form={form} updateForm={updateForm} />}
{currentStep === 6 && <Step6Transfer form={form} updateForm={updateForm} />}
{currentStep === 7 && <Step7Retention form={form} updateForm={updateForm} />}
{currentStep === 8 && <Step8Contracts form={form} updateForm={updateForm} />}
</div>
{/* Navigation Buttons */}
<div className="flex items-center justify-between">
<button
onClick={() => currentStep > 1 ? setCurrentStep(currentStep - 1) : router.push('/sdk/use-cases')}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
{currentStep < 8 ? (
<button
onClick={() => setCurrentStep(currentStep + 1)}
disabled={currentStep === 1 && !form.title}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
Weiter
</button>
) : (
<button
onClick={handleSubmit}
disabled={isSubmitting || !form.title}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{isSubmitting ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Bewerte...
</>
) : (
isEditMode ? 'Speichern & neu bewerten' : 'Assessment starten'
)}
</button>
)}
</div>
<NavigationButtons
currentStep={currentStep}
isSubmitting={isSubmitting}
isEditMode={isEditMode}
titleEmpty={!form.title}
onBack={() => currentStep > 1 ? setCurrentStep(currentStep - 1) : router.push('/sdk/use-cases')}
onNext={() => setCurrentStep(currentStep + 1)}
onSubmit={handleSubmit}
/>
</div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import { AISystem } from './types'
interface AISystemCardProps {
system: AISystem
onAssess: () => void
onEdit: () => void
onDelete: () => void
assessing: boolean
}
const classificationColors: Record<AISystem['classification'], string> = {
prohibited: 'bg-red-100 text-red-700 border-red-200',
'high-risk': 'bg-orange-100 text-orange-700 border-orange-200',
'limited-risk': 'bg-yellow-100 text-yellow-700 border-yellow-200',
'minimal-risk': 'bg-green-100 text-green-700 border-green-200',
unclassified: 'bg-gray-100 text-gray-500 border-gray-200',
}
const classificationLabels: Record<AISystem['classification'], string> = {
prohibited: 'Verboten',
'high-risk': 'Hochrisiko',
'limited-risk': 'Begrenztes Risiko',
'minimal-risk': 'Minimales Risiko',
unclassified: 'Nicht klassifiziert',
}
const statusColors: Record<AISystem['status'], string> = {
draft: 'bg-gray-100 text-gray-500',
classified: 'bg-blue-100 text-blue-700',
compliant: 'bg-green-100 text-green-700',
'non-compliant': 'bg-red-100 text-red-700',
}
const statusLabels: Record<AISystem['status'], string> = {
draft: 'Entwurf',
classified: 'Klassifiziert',
compliant: 'Konform',
'non-compliant': 'Nicht konform',
}
export function AISystemCard({ system, onAssess, onEdit, onDelete, assessing }: AISystemCardProps) {
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
system.classification === 'high-risk' ? 'border-orange-200' :
system.classification === 'prohibited' ? 'border-red-200' : 'border-gray-200'
}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 text-xs rounded-full ${classificationColors[system.classification]}`}>
{classificationLabels[system.classification]}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[system.status]}`}>
{statusLabels[system.status]}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">{system.name}</h3>
<p className="text-sm text-gray-500 mt-1">{system.description}</p>
<div className="mt-2 text-sm text-gray-500">
<span>Sektor: {system.sector}</span>
{system.assessmentDate && (
<span className="ml-4">Klassifiziert: {system.assessmentDate.toLocaleDateString('de-DE')}</span>
)}
</div>
</div>
</div>
{system.obligations.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-sm font-medium text-gray-700 mb-2">Pflichten nach AI Act:</p>
<div className="flex flex-wrap gap-2">
{system.obligations.map(obl => (
<span key={obl} className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">
{obl}
</span>
))}
</div>
</div>
)}
{system.assessmentResult && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg">
<p className="text-xs font-medium text-blue-700">KI-Risikobewertung abgeschlossen</p>
</div>
)}
<div className="mt-4 flex items-center gap-2">
<button
onClick={onAssess}
disabled={assessing}
className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50"
>
{assessing ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Bewertung laeuft...
</span>
) : (
system.classification === 'unclassified' ? 'Klassifizierung starten' : 'Risikobewertung starten'
)}
</button>
<button onClick={onEdit} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Bearbeiten
</button>
<button onClick={onDelete} className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
Loeschen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,102 @@
'use client'
import { useState } from 'react'
import { AISystem } from './types'
interface AddSystemFormProps {
onSubmit: (system: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => void
onCancel: () => void
initialData?: AISystem | null
}
export function AddSystemForm({ onSubmit, onCancel, initialData }: AddSystemFormProps) {
const [formData, setFormData] = useState({
name: initialData?.name || '',
description: initialData?.description || '',
purpose: initialData?.purpose || '',
sector: initialData?.sector || '',
classification: (initialData?.classification || 'unclassified') as AISystem['classification'],
status: (initialData?.status || 'draft') as AISystem['status'],
obligations: initialData?.obligations || [] as string[],
})
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{initialData ? 'KI-System bearbeiten' : 'Neues KI-System registrieren'}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Dokumenten-Scanner"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="Beschreiben Sie das KI-System..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck</label>
<input
type="text"
value={formData.purpose}
onChange={e => setFormData({ ...formData, purpose: e.target.value })}
placeholder="z.B. Texterkennung"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sektor</label>
<input
type="text"
value={formData.sector}
onChange={e => setFormData({ ...formData, sector: e.target.value })}
placeholder="z.B. Verwaltung"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vorklassifizierung</label>
<select
value={formData.classification}
onChange={e => setFormData({ ...formData, classification: e.target.value as AISystem['classification'] })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="unclassified">Noch nicht klassifiziert</option>
<option value="minimal-risk">Minimales Risiko</option>
<option value="limited-risk">Begrenztes Risiko</option>
<option value="high-risk">Hochrisiko</option>
<option value="prohibited">Verboten</option>
</select>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={() => onSubmit(formData)}
disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{initialData ? 'Speichern' : 'Registrieren'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
'use client'
export function LoadingSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[1, 2, 3, 4].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-center gap-2 mb-3">
<div className="h-5 w-24 bg-gray-200 rounded-full" />
<div className="h-5 w-20 bg-gray-200 rounded-full" />
</div>
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded mb-4" />
<div className="h-4 w-1/2 bg-gray-100 rounded" />
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="flex gap-2">
<div className="h-8 flex-1 bg-gray-200 rounded-lg" />
<div className="h-8 w-24 bg-gray-200 rounded-lg" />
</div>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,39 @@
'use client'
import { AISystem } from './types'
interface RiskPyramidProps {
systems: AISystem[]
}
export function RiskPyramid({ systems }: RiskPyramidProps) {
const counts = {
prohibited: systems.filter(s => s.classification === 'prohibited').length,
'high-risk': systems.filter(s => s.classification === 'high-risk').length,
'limited-risk': systems.filter(s => s.classification === 'limited-risk').length,
'minimal-risk': systems.filter(s => s.classification === 'minimal-risk').length,
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">AI Act Risikopyramide</h3>
<div className="flex flex-col items-center space-y-1">
<div className="w-24 h-12 bg-red-500 text-white flex items-center justify-center rounded-t-lg text-sm font-medium">
Verboten ({counts.prohibited})
</div>
<div className="w-40 h-12 bg-orange-500 text-white flex items-center justify-center text-sm font-medium">
Hochrisiko ({counts['high-risk']})
</div>
<div className="w-56 h-12 bg-yellow-500 text-white flex items-center justify-center text-sm font-medium">
Begrenztes Risiko ({counts['limited-risk']})
</div>
<div className="w-72 h-12 bg-green-500 text-white flex items-center justify-center rounded-b-lg text-sm font-medium">
Minimales Risiko ({counts['minimal-risk']})
</div>
</div>
<div className="mt-4 text-center text-sm text-gray-500">
{systems.filter(s => s.classification === 'unclassified').length} System(e) noch nicht klassifiziert
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
export interface AISystem {
id: string
name: string
description: string
classification: 'prohibited' | 'high-risk' | 'limited-risk' | 'minimal-risk' | 'unclassified'
purpose: string
sector: string
status: 'draft' | 'classified' | 'compliant' | 'non-compliant'
obligations: string[]
assessmentDate: Date | null
assessmentResult: Record<string, unknown> | null
}

View File

@@ -3,312 +3,11 @@
import React, { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// TYPES
// =============================================================================
interface AISystem {
id: string
name: string
description: string
classification: 'prohibited' | 'high-risk' | 'limited-risk' | 'minimal-risk' | 'unclassified'
purpose: string
sector: string
status: 'draft' | 'classified' | 'compliant' | 'non-compliant'
obligations: string[]
assessmentDate: Date | null
assessmentResult: Record<string, unknown> | null
}
// =============================================================================
// LOADING SKELETON
// =============================================================================
function LoadingSkeleton() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[1, 2, 3, 4].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-center gap-2 mb-3">
<div className="h-5 w-24 bg-gray-200 rounded-full" />
<div className="h-5 w-20 bg-gray-200 rounded-full" />
</div>
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded mb-4" />
<div className="h-4 w-1/2 bg-gray-100 rounded" />
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="flex gap-2">
<div className="h-8 flex-1 bg-gray-200 rounded-lg" />
<div className="h-8 w-24 bg-gray-200 rounded-lg" />
</div>
</div>
</div>
))}
</div>
)
}
// =============================================================================
// COMPONENTS
// =============================================================================
function RiskPyramid({ systems }: { systems: AISystem[] }) {
const counts = {
prohibited: systems.filter(s => s.classification === 'prohibited').length,
'high-risk': systems.filter(s => s.classification === 'high-risk').length,
'limited-risk': systems.filter(s => s.classification === 'limited-risk').length,
'minimal-risk': systems.filter(s => s.classification === 'minimal-risk').length,
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">AI Act Risikopyramide</h3>
<div className="flex flex-col items-center space-y-1">
<div className="w-24 h-12 bg-red-500 text-white flex items-center justify-center rounded-t-lg text-sm font-medium">
Verboten ({counts.prohibited})
</div>
<div className="w-40 h-12 bg-orange-500 text-white flex items-center justify-center text-sm font-medium">
Hochrisiko ({counts['high-risk']})
</div>
<div className="w-56 h-12 bg-yellow-500 text-white flex items-center justify-center text-sm font-medium">
Begrenztes Risiko ({counts['limited-risk']})
</div>
<div className="w-72 h-12 bg-green-500 text-white flex items-center justify-center rounded-b-lg text-sm font-medium">
Minimales Risiko ({counts['minimal-risk']})
</div>
</div>
<div className="mt-4 text-center text-sm text-gray-500">
{systems.filter(s => s.classification === 'unclassified').length} System(e) noch nicht klassifiziert
</div>
</div>
)
}
function AddSystemForm({
onSubmit,
onCancel,
initialData,
}: {
onSubmit: (system: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => void
onCancel: () => void
initialData?: AISystem | null
}) {
const [formData, setFormData] = useState({
name: initialData?.name || '',
description: initialData?.description || '',
purpose: initialData?.purpose || '',
sector: initialData?.sector || '',
classification: (initialData?.classification || 'unclassified') as AISystem['classification'],
status: (initialData?.status || 'draft') as AISystem['status'],
obligations: initialData?.obligations || [] as string[],
})
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{initialData ? 'KI-System bearbeiten' : 'Neues KI-System registrieren'}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Dokumenten-Scanner"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="Beschreiben Sie das KI-System..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck</label>
<input
type="text"
value={formData.purpose}
onChange={e => setFormData({ ...formData, purpose: e.target.value })}
placeholder="z.B. Texterkennung"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sektor</label>
<input
type="text"
value={formData.sector}
onChange={e => setFormData({ ...formData, sector: e.target.value })}
placeholder="z.B. Verwaltung"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vorklassifizierung</label>
<select
value={formData.classification}
onChange={e => setFormData({ ...formData, classification: e.target.value as AISystem['classification'] })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="unclassified">Noch nicht klassifiziert</option>
<option value="minimal-risk">Minimales Risiko</option>
<option value="limited-risk">Begrenztes Risiko</option>
<option value="high-risk">Hochrisiko</option>
<option value="prohibited">Verboten</option>
</select>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={() => onSubmit(formData)}
disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{initialData ? 'Speichern' : 'Registrieren'}
</button>
</div>
</div>
)
}
function AISystemCard({
system,
onAssess,
onEdit,
onDelete,
assessing,
}: {
system: AISystem
onAssess: () => void
onEdit: () => void
onDelete: () => void
assessing: boolean
}) {
const classificationColors = {
prohibited: 'bg-red-100 text-red-700 border-red-200',
'high-risk': 'bg-orange-100 text-orange-700 border-orange-200',
'limited-risk': 'bg-yellow-100 text-yellow-700 border-yellow-200',
'minimal-risk': 'bg-green-100 text-green-700 border-green-200',
unclassified: 'bg-gray-100 text-gray-500 border-gray-200',
}
const classificationLabels = {
prohibited: 'Verboten',
'high-risk': 'Hochrisiko',
'limited-risk': 'Begrenztes Risiko',
'minimal-risk': 'Minimales Risiko',
unclassified: 'Nicht klassifiziert',
}
const statusColors = {
draft: 'bg-gray-100 text-gray-500',
classified: 'bg-blue-100 text-blue-700',
compliant: 'bg-green-100 text-green-700',
'non-compliant': 'bg-red-100 text-red-700',
}
const statusLabels = {
draft: 'Entwurf',
classified: 'Klassifiziert',
compliant: 'Konform',
'non-compliant': 'Nicht konform',
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
system.classification === 'high-risk' ? 'border-orange-200' :
system.classification === 'prohibited' ? 'border-red-200' : 'border-gray-200'
}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 text-xs rounded-full ${classificationColors[system.classification]}`}>
{classificationLabels[system.classification]}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[system.status]}`}>
{statusLabels[system.status]}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">{system.name}</h3>
<p className="text-sm text-gray-500 mt-1">{system.description}</p>
<div className="mt-2 text-sm text-gray-500">
<span>Sektor: {system.sector}</span>
{system.assessmentDate && (
<span className="ml-4">Klassifiziert: {system.assessmentDate.toLocaleDateString('de-DE')}</span>
)}
</div>
</div>
</div>
{system.obligations.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-sm font-medium text-gray-700 mb-2">Pflichten nach AI Act:</p>
<div className="flex flex-wrap gap-2">
{system.obligations.map(obl => (
<span key={obl} className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">
{obl}
</span>
))}
</div>
</div>
)}
{system.assessmentResult && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg">
<p className="text-xs font-medium text-blue-700">KI-Risikobewertung abgeschlossen</p>
</div>
)}
<div className="mt-4 flex items-center gap-2">
<button
onClick={onAssess}
disabled={assessing}
className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50"
>
{assessing ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Bewertung laeuft...
</span>
) : (
system.classification === 'unclassified' ? 'Klassifizierung starten' : 'Risikobewertung starten'
)}
</button>
<button
onClick={onEdit}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Bearbeiten
</button>
<button
onClick={onDelete}
className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
import { AISystem } from './_components/types'
import { LoadingSkeleton } from './_components/LoadingSkeleton'
import { RiskPyramid } from './_components/RiskPyramid'
import { AddSystemForm } from './_components/AddSystemForm'
import { AISystemCard } from './_components/AISystemCard'
export default function AIActPage() {
const { state } = useSDK()
@@ -320,7 +19,6 @@ export default function AIActPage() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
// Fetch systems from backend on mount
useEffect(() => {
const fetchSystems = async () => {
setLoading(true)
@@ -354,59 +52,43 @@ export default function AIActPage() {
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
setError(null)
if (editingSystem) {
// Edit existing system via PUT
try {
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: data.name,
description: data.description,
purpose: data.purpose,
sector: data.sector,
classification: data.classification,
status: data.status,
obligations: data.obligations,
name: data.name, description: data.description, purpose: data.purpose,
sector: data.sector, classification: data.classification,
status: data.status, obligations: data.obligations,
}),
})
if (res.ok) {
const updated = await res.json()
setSystems(prev => prev.map(s =>
s.id === editingSystem.id
? { ...s, ...data, id: updated.id || editingSystem.id }
: s
s.id === editingSystem.id ? { ...s, ...data, id: updated.id || editingSystem.id } : s
))
} else {
setError('Speichern fehlgeschlagen')
}
} catch {
// Fallback: update locally
setSystems(prev => prev.map(s =>
s.id === editingSystem.id ? { ...s, ...data } : s
))
setSystems(prev => prev.map(s => s.id === editingSystem.id ? { ...s, ...data } : s))
}
setEditingSystem(null)
} else {
// Create new system via POST
try {
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: data.name,
description: data.description,
purpose: data.purpose,
sector: data.sector,
classification: data.classification,
status: data.status,
obligations: data.obligations,
name: data.name, description: data.description, purpose: data.purpose,
sector: data.sector, classification: data.classification,
status: data.status, obligations: data.obligations,
}),
})
if (res.ok) {
const created = await res.json()
const newSystem: AISystem = {
...data,
id: created.id,
...data, id: created.id,
assessmentDate: created.assessment_date ? new Date(created.assessment_date) : null,
assessmentResult: created.assessment_result || null,
}
@@ -415,10 +97,8 @@ export default function AIActPage() {
setError('Registrierung fehlgeschlagen')
}
} catch {
// Fallback: add locally
const newSystem: AISystem = {
...data,
id: `ai-${Date.now()}`,
...data, id: `ai-${Date.now()}`,
assessmentDate: data.classification !== 'unclassified' ? new Date() : null,
assessmentResult: null,
}
@@ -450,16 +130,13 @@ export default function AIActPage() {
const handleAssess = async (systemId: string) => {
setAssessingId(systemId)
setError(null)
try {
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${systemId}/assess`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
const result = await res.json()
setSystems(prev => prev.map(s =>
s.id === systemId
? {
@@ -490,12 +167,10 @@ export default function AIActPage() {
const highRiskCount = systems.filter(s => s.classification === 'high-risk').length
const compliantCount = systems.filter(s => s.status === 'compliant').length
const unclassifiedCount = systems.filter(s => s.classification === 'unclassified').length
const stepInfo = STEP_EXPLANATIONS['ai-act']
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="ai-act"
title={stepInfo.title}
@@ -514,7 +189,6 @@ export default function AIActPage() {
</button>
</StepHeader>
{/* Error Banner */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
@@ -522,7 +196,6 @@ export default function AIActPage() {
</div>
)}
{/* Add/Edit System Form */}
{showAddForm && (
<AddSystemForm
onSubmit={handleAddSystem}
@@ -531,7 +204,6 @@ export default function AIActPage() {
/>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">KI-Systeme gesamt</div>
@@ -551,10 +223,8 @@ export default function AIActPage() {
</div>
</div>
{/* Risk Pyramid */}
<RiskPyramid systems={systems} />
{/* Filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
@@ -562,9 +232,7 @@ export default function AIActPage() {
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filter === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' :
@@ -577,10 +245,8 @@ export default function AIActPage() {
))}
</div>
{/* Loading */}
{loading && <LoadingSkeleton />}
{/* AI Systems List */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{filteredSystems.map(system => (

View File

@@ -0,0 +1,164 @@
'use client'
import React, { useCallback, useEffect } from 'react'
import ReactFlow, {
Node,
Controls,
Background,
MiniMap,
useNodesState,
useEdgesState,
BackgroundVariant,
Panel,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { ARCH_SERVICES, LAYERS, type ArchService } from '../architecture-data'
import { LAYER_ORDER, type LayerFilter } from '../_layout'
import { useArchGraph } from '../_hooks/useArchGraph'
import DetailPanel from './DetailPanel'
export default function ArchCanvas({
layerFilter,
showDb,
showRag,
showApis,
selectedService,
setSelectedService,
}: {
layerFilter: LayerFilter
showDb: boolean
showRag: boolean
showApis: boolean
selectedService: ArchService | null
setSelectedService: React.Dispatch<React.SetStateAction<ArchService | null>>
}) {
const { nodes: initialNodes, edges: initialEdges } = useArchGraph({
layerFilter,
showDb,
showRag,
showApis,
selectedService,
})
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
useEffect(() => {
setNodes(initialNodes)
setEdges(initialEdges)
}, [initialNodes, initialEdges, setNodes, setEdges])
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
const service = ARCH_SERVICES.find(s => s.id === node.id)
if (service) {
setSelectedService(prev => (prev?.id === service.id ? null : service))
}
}, [setSelectedService])
const onPaneClick = useCallback(() => {
setSelectedService(null)
}, [setSelectedService])
return (
<div className="flex bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '700px' }}>
{/* Canvas */}
<div className="flex-1 relative">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
fitView
fitViewOptions={{ padding: 0.15 }}
attributionPosition="bottom-left"
>
<Controls />
<MiniMap
nodeColor={node => {
if (node.id.startsWith('db-')) return '#94a3b8'
if (node.id.startsWith('rag-')) return '#22c55e'
if (node.id.startsWith('api-')) return '#c4b5fd'
const svc = ARCH_SERVICES.find(s => s.id === node.id)
return svc ? LAYERS[svc.layer].colorBorder : '#94a3b8'
}}
maskColor="rgba(0,0,0,0.08)"
/>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
{/* Legende */}
<Panel
position="bottom-right"
className="bg-white/95 p-3 rounded-lg shadow-lg text-xs"
>
<div className="font-medium text-slate-700 mb-2">Legende</div>
<div className="space-y-1">
{LAYER_ORDER.map(layerId => {
const layer = LAYERS[layerId]
return (
<div key={layerId} className="flex items-center gap-2">
<span
className="w-3 h-3 rounded"
style={{
background: layer.colorBg,
border: `1px solid ${layer.colorBorder}`,
}}
/>
<span className="text-slate-600">{layer.name}</span>
</div>
)
})}
<div className="border-t border-slate-200 my-1.5 pt-1.5">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-slate-200 border border-slate-400" />
<span className="text-slate-500">DB-Tabelle</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="w-3 h-3 rounded bg-green-100 border border-green-500" />
<span className="text-slate-500">RAG-Collection</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="w-3 h-3 rounded bg-violet-100 border border-violet-400" />
<span className="text-slate-500">API-Endpunkt</span>
</div>
</div>
</div>
</Panel>
{/* Swim-Lane Labels */}
{layerFilter === 'alle' && (
<Panel position="top-left" className="pointer-events-none">
<div className="space-y-1">
{LAYER_ORDER.map(layerId => {
const layer = LAYERS[layerId]
return (
<div
key={layerId}
className="px-3 py-1 rounded text-xs font-medium opacity-50"
style={{
background: layer.colorBg,
color: layer.colorText,
border: `1px solid ${layer.colorBorder}`,
}}
>
{layer.name}
</div>
)
})}
</div>
</Panel>
)}
</ReactFlow>
</div>
{/* Detail Panel */}
{selectedService && (
<DetailPanel
service={selectedService}
onClose={() => setSelectedService(null)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
export default function ArchHeader({
stats,
}: {
stats: {
services: number
dbTables: number
ragCollections: number
edges: number
}
}) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-700 flex items-center justify-center text-white">
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div>
<h2 className="text-xl font-bold text-slate-900">
Architektur-Uebersicht
</h2>
<p className="text-sm text-slate-500">
{stats.services} Services | {stats.dbTables} DB-Tabellen | {stats.ragCollections} RAG-Collections | {stats.edges} Verbindungen
</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,164 @@
'use client'
import { ARCH_SERVICES, LAYERS, type ArchService } from '../architecture-data'
export default function DetailPanel({
service,
onClose,
}: {
service: ArchService
onClose: () => void
}) {
const layer = LAYERS[service.layer]
return (
<div className="w-80 bg-white border-l border-slate-200 overflow-y-auto">
<div className="sticky top-0 bg-white z-10 border-b border-slate-200">
<div className="flex items-center justify-between p-4">
<h3 className="font-bold text-slate-900">{service.name}</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 text-lg leading-none"
>
x
</button>
</div>
<div className="px-4 pb-3 flex items-center gap-2">
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{ background: layer.colorBg, color: layer.colorText }}
>
{layer.name}
</span>
<span className="text-xs text-slate-400">{service.tech}</span>
</div>
</div>
<div className="p-4 space-y-4">
{/* Beschreibung */}
<p className="text-sm text-slate-700 leading-relaxed">{service.description}</p>
<p className="text-xs text-slate-500 leading-relaxed mt-1">{service.descriptionLong}</p>
{/* Tech + Port + Container */}
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Tech</span>
<span className="font-medium text-slate-800">{service.tech}</span>
</div>
{service.port && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Port</span>
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded">{service.port}</code>
</div>
)}
{service.url && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">URL</span>
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline truncate max-w-[180px]"
>
{service.url}
</a>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Container</span>
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded truncate max-w-[180px]">{service.container}</code>
</div>
</div>
{/* DB Tables */}
{service.dbTables.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
DB-Tabellen ({service.dbTables.length})
</h4>
<div className="space-y-1">
{service.dbTables.map(table => (
<div
key={table}
className="text-sm bg-slate-100 rounded px-2 py-1"
>
<code className="text-slate-700 text-xs">{table}</code>
</div>
))}
</div>
</div>
)}
{/* RAG Collections */}
{service.ragCollections.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
RAG-Collections ({service.ragCollections.length})
</h4>
<div className="space-y-1">
{service.ragCollections.map(rag => (
<div
key={rag}
className="text-sm bg-green-50 rounded px-2 py-1"
>
<code className="text-green-700 text-xs">{rag}</code>
</div>
))}
</div>
</div>
)}
{/* API Endpoints */}
{service.apiEndpoints.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
API-Endpunkte ({service.apiEndpoints.length})
</h4>
<div className="space-y-1">
{service.apiEndpoints.map(ep => (
<div
key={ep}
className="text-sm bg-violet-50 rounded px-2 py-1"
>
<code className="text-violet-700 text-xs">{ep}</code>
</div>
))}
</div>
</div>
)}
{/* Dependencies */}
{service.dependsOn.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
Abhaengigkeiten
</h4>
<div className="space-y-1">
{service.dependsOn.map(depId => {
const dep = ARCH_SERVICES.find(s => s.id === depId)
return (
<div
key={depId}
className="text-sm text-slate-600 bg-slate-50 rounded px-2 py-1"
>
{dep?.name || depId}
</div>
)
})}
</div>
</div>
)}
{/* Open URL */}
{service.url && (
<button
onClick={() => window.open(service.url!, '_blank')}
className="w-full mt-2 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors"
>
Service oeffnen
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,253 @@
'use client'
import React from 'react'
import { ARCH_SERVICES, LAYERS, type ArchService } from '../architecture-data'
import { type LayerFilter } from '../_layout'
export default function ServiceTable({
layerFilter,
expandedServices,
onToggleExpanded,
onMarkInGraph,
}: {
layerFilter: LayerFilter
expandedServices: Set<string>
onToggleExpanded: (id: string) => void
onMarkInGraph: (service: ArchService) => void
}) {
const filtered = ARCH_SERVICES.filter(
s => layerFilter === 'alle' || s.layer === layerFilter
)
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b">
<h3 className="font-medium text-slate-700">
Alle Services ({
layerFilter === 'alle'
? ARCH_SERVICES.length
: `${ARCH_SERVICES.filter(s => s.layer === layerFilter).length} / ${ARCH_SERVICES.length}`
})
</h3>
</div>
<div className="divide-y max-h-[600px] overflow-y-auto">
{filtered.map(service => {
const layer = LAYERS[service.layer]
const isExpanded = expandedServices.has(service.id)
return (
<div key={service.id}>
{/* Row Header */}
<button
onClick={() => onToggleExpanded(service.id)}
className={`w-full flex items-center gap-3 p-3 text-left transition-colors ${
isExpanded
? 'bg-purple-50'
: 'hover:bg-slate-50'
}`}
>
{/* Chevron */}
<svg
className={`w-4 h-4 text-slate-400 shrink-0 transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0"
style={{ background: layer.colorBg, color: layer.colorText }}
>
{service.port ? `:${service.port}` : '--'}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 text-sm">
{service.name}
</div>
<div className="text-xs text-slate-500 flex items-center gap-2 mt-0.5">
<span className="text-slate-400">{service.tech}</span>
{service.dbTables.length > 0 && (
<span className="text-slate-400">
DB: {service.dbTables.length}
</span>
)}
{service.ragCollections.length > 0 && (
<span className="text-green-600">
RAG: {service.ragCollections.length}
</span>
)}
{service.apiEndpoints.length > 0 && (
<span className="text-violet-600">
API: {service.apiEndpoints.length}
</span>
)}
</div>
</div>
<code className="text-[10px] text-slate-400 shrink-0 hidden sm:block">
{service.container}
</code>
<span
className="px-2 py-0.5 rounded text-[10px] font-medium shrink-0"
style={{ background: layer.colorBg, color: layer.colorText }}
>
{layer.name}
</span>
</button>
{/* Expanded Detail */}
{isExpanded && <ExpandedRow service={service} onMarkInGraph={onMarkInGraph} />}
</div>
)
})}
</div>
</div>
)
}
function ExpandedRow({
service,
onMarkInGraph,
}: {
service: ArchService
onMarkInGraph: (service: ArchService) => void
}) {
return (
<div className="px-4 pb-4 pt-1 bg-slate-50/50 border-t border-slate-100">
{/* Beschreibung */}
<p className="text-sm text-slate-700 leading-relaxed">
{service.description}
</p>
<p className="text-xs text-slate-500 leading-relaxed mt-1 mb-3">
{service.descriptionLong}
</p>
{/* Info Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
<div className="text-[10px] font-semibold text-slate-400 uppercase">Tech</div>
<div className="text-sm font-medium text-slate-800 mt-0.5">{service.tech}</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
<div className="text-[10px] font-semibold text-slate-400 uppercase">Port</div>
<div className="text-sm font-medium text-slate-800 mt-0.5">
{service.port ? `:${service.port}` : 'Intern'}
</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
<div className="text-[10px] font-semibold text-slate-400 uppercase">Container</div>
<div className="text-xs font-mono text-slate-700 mt-0.5 truncate">{service.container}</div>
</div>
{service.url && (
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
<div className="text-[10px] font-semibold text-slate-400 uppercase">URL</div>
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline mt-0.5 block truncate"
>
{service.url}
</a>
</div>
)}
</div>
{/* Sections */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{service.dbTables.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-3">
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
DB-Tabellen ({service.dbTables.length})
</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{service.dbTables.map(table => (
<div key={table} className="bg-slate-50 rounded px-2 py-1">
<code className="text-xs text-slate-700">{table}</code>
</div>
))}
</div>
</div>
)}
{service.ragCollections.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-3">
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
RAG-Collections ({service.ragCollections.length})
</h4>
<div className="space-y-1">
{service.ragCollections.map(rag => (
<div key={rag} className="bg-green-50 rounded px-2 py-1">
<code className="text-xs text-green-700">{rag}</code>
</div>
))}
</div>
</div>
)}
{service.apiEndpoints.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-3">
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
API-Endpunkte ({service.apiEndpoints.length})
</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{service.apiEndpoints.map(ep => (
<div key={ep} className="bg-violet-50 rounded px-2 py-1">
<code className="text-xs text-violet-700">{ep}</code>
</div>
))}
</div>
</div>
)}
{service.dependsOn.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-3">
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
Abhaengigkeiten ({service.dependsOn.length})
</h4>
<div className="space-y-1">
{service.dependsOn.map(depId => {
const dep = ARCH_SERVICES.find(s => s.id === depId)
const depLayer = dep ? LAYERS[dep.layer] : null
return (
<div key={depId} className="flex items-center gap-2 bg-slate-50 rounded px-2 py-1">
{depLayer && (
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ background: depLayer.colorBorder }}
/>
)}
<span className="text-xs text-slate-700">{dep?.name || depId}</span>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Open in Graph + URL */}
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => {
onMarkInGraph(service)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium hover:bg-slate-200 transition-colors"
>
Im Graph markieren
</button>
{service.url && (
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 transition-colors"
>
Service oeffnen
</a>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { ARCH_SERVICES, LAYERS } from '../architecture-data'
import { LAYER_ORDER, type LayerFilter } from '../_layout'
export default function Toolbar({
layerFilter,
showDb,
showRag,
showApis,
onLayerFilter,
onToggleDb,
onToggleRag,
onToggleApis,
}: {
layerFilter: LayerFilter
showDb: boolean
showRag: boolean
showApis: boolean
onLayerFilter: (f: LayerFilter) => void
onToggleDb: () => void
onToggleRag: () => void
onToggleApis: () => void
}) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
{/* Layer Filter */}
<button
onClick={() => onLayerFilter('alle')}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
layerFilter === 'alle'
? 'bg-slate-800 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Alle ({ARCH_SERVICES.length})
</button>
{LAYER_ORDER.map(layerId => {
const layer = LAYERS[layerId]
const count = ARCH_SERVICES.filter(s => s.layer === layerId).length
return (
<button
key={layerId}
onClick={() => onLayerFilter(layerFilter === layerId ? 'alle' : layerId)}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-all flex items-center gap-1.5"
style={{
background: layerFilter === layerId ? layer.colorBorder : layer.colorBg,
color: layerFilter === layerId ? 'white' : layer.colorText,
}}
>
<span
className="w-2.5 h-2.5 rounded-full"
style={{ background: layer.colorBorder }}
/>
{layer.name} ({count})
</button>
)
})}
{/* Separator */}
<div className="w-px h-6 bg-slate-200 mx-1" />
{/* Toggles */}
<button
onClick={onToggleDb}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
showDb
? 'bg-slate-700 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
DB-Tabellen
</button>
<button
onClick={onToggleRag}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
showRag
? 'bg-green-600 text-white'
: 'bg-green-50 text-green-700 hover:bg-green-100'
}`}
>
RAG-Collections
</button>
<button
onClick={onToggleApis}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
showApis
? 'bg-violet-600 text-white'
: 'bg-violet-50 text-violet-700 hover:bg-violet-100'
}`}
>
API-Endpunkte
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,251 @@
'use client'
import { useMemo } from 'react'
import { type Node, type Edge, MarkerType } from 'reactflow'
import {
ARCH_SERVICES,
ARCH_EDGES,
LAYERS,
type ArchService,
} from '../architecture-data'
import {
NODE_WIDTH,
LANE_Y_START,
getServicePosition,
type LayerFilter,
} from '../_layout'
export function useArchGraph({
layerFilter,
showDb,
showRag,
showApis,
selectedService,
}: {
layerFilter: LayerFilter
showDb: boolean
showRag: boolean
showApis: boolean
selectedService: ArchService | null
}) {
return useMemo(() => {
const nodes: Node[] = []
const edges: Edge[] = []
const visibleServices =
layerFilter === 'alle'
? ARCH_SERVICES
: ARCH_SERVICES.filter(s => s.layer === layerFilter)
const visibleIds = new Set(visibleServices.map(s => s.id))
// ── Service Nodes ──────────────────────────────────────────────────────
visibleServices.forEach(service => {
const layer = LAYERS[service.layer]
const pos = getServicePosition(service)
const isSelected = selectedService?.id === service.id
nodes.push({
id: service.id,
type: 'default',
position: pos,
data: {
label: (
<div className="text-center px-1">
<div className="font-medium text-xs leading-tight">
{service.nameShort}
</div>
<div className="text-[10px] opacity-70 mt-0.5">
{service.tech}
</div>
{service.port && (
<div className="text-[9px] opacity-50 mt-0.5">
:{service.port}
</div>
)}
</div>
),
},
style: {
background: isSelected ? layer.colorBorder : layer.colorBg,
color: isSelected ? 'white' : layer.colorText,
border: `2px solid ${layer.colorBorder}`,
borderRadius: '10px',
padding: '8px 4px',
minWidth: `${NODE_WIDTH}px`,
maxWidth: `${NODE_WIDTH}px`,
cursor: 'pointer',
boxShadow: isSelected
? `0 0 16px ${layer.colorBorder}`
: '0 1px 3px rgba(0,0,0,0.08)',
},
})
})
// ── Connection Edges ───────────────────────────────────────────────────
ARCH_EDGES.forEach(archEdge => {
if (visibleIds.has(archEdge.source) && visibleIds.has(archEdge.target)) {
const isHighlighted =
selectedService?.id === archEdge.source ||
selectedService?.id === archEdge.target
edges.push({
id: `e-${archEdge.source}-${archEdge.target}`,
source: archEdge.source,
target: archEdge.target,
type: 'smoothstep',
animated: isHighlighted,
label: archEdge.label,
labelStyle: { fontSize: 9, fill: isHighlighted ? '#7c3aed' : '#94a3b8' },
style: {
stroke: isHighlighted ? '#7c3aed' : '#94a3b8',
strokeWidth: isHighlighted ? 2.5 : 1.5,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: isHighlighted ? '#7c3aed' : '#94a3b8',
width: 14,
height: 14,
},
})
}
})
// ── DB Table Nodes ─────────────────────────────────────────────────────
if (showDb) {
const dbTablesInUse = new Set<string>()
visibleServices.forEach(s => s.dbTables.forEach(t => dbTablesInUse.add(t)))
let dbIdx = 0
dbTablesInUse.forEach(table => {
const nodeId = `db-${table}`
nodes.push({
id: nodeId,
type: 'default',
position: { x: -250, y: LANE_Y_START + dbIdx * 60 },
data: {
label: (
<div className="text-center">
<div className="font-medium text-[10px] leading-tight">{table}</div>
</div>
),
},
style: {
background: '#f1f5f9',
color: '#475569',
border: '1px solid #94a3b8',
borderRadius: '6px',
padding: '4px 6px',
fontSize: '10px',
minWidth: '140px',
},
})
visibleServices
.filter(s => s.dbTables.includes(table))
.forEach(svc => {
edges.push({
id: `e-db-${table}-${svc.id}`,
source: nodeId,
target: svc.id,
type: 'straight',
style: { stroke: '#94a3b8', strokeWidth: 1, strokeDasharray: '6 3' },
})
})
dbIdx++
})
}
// ── RAG Collection Nodes ───────────────────────────────────────────────
if (showRag) {
const ragInUse = new Set<string>()
visibleServices.forEach(s => s.ragCollections.forEach(r => ragInUse.add(r)))
let ragIdx = 0
ragInUse.forEach(collection => {
const nodeId = `rag-${collection}`
const rightX = 1200
nodes.push({
id: nodeId,
type: 'default',
position: { x: rightX, y: LANE_Y_START + ragIdx * 60 },
data: {
label: (
<div className="text-center">
<div className="font-medium text-[10px] leading-tight">
{collection.replace('bp_', '')}
</div>
</div>
),
},
style: {
background: '#dcfce7',
color: '#166534',
border: '1px solid #22c55e',
borderRadius: '6px',
padding: '4px 6px',
fontSize: '10px',
minWidth: '130px',
},
})
visibleServices
.filter(s => s.ragCollections.includes(collection))
.forEach(svc => {
edges.push({
id: `e-rag-${collection}-${svc.id}`,
source: nodeId,
target: svc.id,
type: 'straight',
style: { stroke: '#22c55e', strokeWidth: 1, strokeDasharray: '6 3' },
})
})
ragIdx++
})
}
// ── API Endpoint Nodes ─────────────────────────────────────────────────
if (showApis) {
visibleServices.forEach(svc => {
if (svc.apiEndpoints.length === 0) return
const svcPos = getServicePosition(svc)
svc.apiEndpoints.forEach((ep, idx) => {
const nodeId = `api-${svc.id}-${idx}`
nodes.push({
id: nodeId,
type: 'default',
position: { x: svcPos.x + NODE_WIDTH + 30, y: svcPos.y + idx * 32 },
data: {
label: (
<div className="text-[9px] font-mono leading-tight truncate">{ep}</div>
),
},
style: {
background: '#faf5ff',
color: '#7c3aed',
border: '1px solid #c4b5fd',
borderRadius: '4px',
padding: '2px 6px',
fontSize: '9px',
minWidth: '160px',
},
})
edges.push({
id: `e-api-${svc.id}-${idx}`,
source: svc.id,
target: nodeId,
type: 'straight',
style: { stroke: '#c4b5fd', strokeWidth: 1 },
})
})
})
}
return { nodes, edges }
}, [layerFilter, showDb, showRag, showApis, selectedService])
}

View File

@@ -0,0 +1,30 @@
import { ARCH_SERVICES, LAYERS, type ArchService, type ServiceLayer } from './architecture-data'
// =============================================================================
// TYPES
// =============================================================================
export type LayerFilter = 'alle' | ServiceLayer
// =============================================================================
// LAYOUT
// =============================================================================
export const NODE_WIDTH = 180
export const NODE_HEIGHT = 70
export const NODE_X_SPACING = 220
export const LANE_Y_START = 80
export const LANE_LABEL_HEIGHT = 40
export const LAYER_ORDER: ServiceLayer[] = ['frontend', 'backend', 'infrastructure', 'data-sovereignty']
export function getServicePosition(service: ArchService): { x: number; y: number } {
const layer = LAYERS[service.layer]
const layerServices = ARCH_SERVICES.filter(s => s.layer === service.layer)
const idx = layerServices.findIndex(s => s.id === service.id)
return {
x: 80 + idx * NODE_X_SPACING,
y: LANE_Y_START + LANE_LABEL_HEIGHT + layer.y,
}
}

View File

@@ -8,228 +8,19 @@
* Analog zum SDK-Flow, aber fuer die Service-Topologie.
*/
import { useCallback, useState, useMemo, useEffect } from 'react'
import ReactFlow, {
Node,
Edge,
Controls,
Background,
MiniMap,
useNodesState,
useEdgesState,
BackgroundVariant,
MarkerType,
Panel,
} from 'reactflow'
import 'reactflow/dist/style.css'
import { useCallback, useState, useMemo } from 'react'
import {
ARCH_SERVICES,
ARCH_EDGES,
LAYERS,
getAllDbTables,
getAllRagCollections,
type ArchService,
type ServiceLayer,
} from './architecture-data'
// =============================================================================
// TYPES
// =============================================================================
type LayerFilter = 'alle' | ServiceLayer
// =============================================================================
// LAYOUT
// =============================================================================
const NODE_WIDTH = 180
const NODE_HEIGHT = 70
const NODE_X_SPACING = 220
const LANE_Y_START = 80
const LANE_LABEL_HEIGHT = 40
const LAYER_ORDER: ServiceLayer[] = ['frontend', 'backend', 'infrastructure', 'data-sovereignty']
function getServicePosition(service: ArchService): { x: number; y: number } {
const layer = LAYERS[service.layer]
const layerServices = ARCH_SERVICES.filter(s => s.layer === service.layer)
const idx = layerServices.findIndex(s => s.id === service.id)
return {
x: 80 + idx * NODE_X_SPACING,
y: LANE_Y_START + LANE_LABEL_HEIGHT + layer.y,
}
}
// =============================================================================
// DETAIL PANEL
// =============================================================================
function DetailPanel({
service,
onClose,
}: {
service: ArchService
onClose: () => void
}) {
const layer = LAYERS[service.layer]
return (
<div className="w-80 bg-white border-l border-slate-200 overflow-y-auto">
<div className="sticky top-0 bg-white z-10 border-b border-slate-200">
<div className="flex items-center justify-between p-4">
<h3 className="font-bold text-slate-900">{service.name}</h3>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 text-lg leading-none"
>
x
</button>
</div>
<div className="px-4 pb-3 flex items-center gap-2">
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{ background: layer.colorBg, color: layer.colorText }}
>
{layer.name}
</span>
<span className="text-xs text-slate-400">{service.tech}</span>
</div>
</div>
<div className="p-4 space-y-4">
{/* Beschreibung */}
<p className="text-sm text-slate-700 leading-relaxed">{service.description}</p>
<p className="text-xs text-slate-500 leading-relaxed mt-1">{service.descriptionLong}</p>
{/* Tech + Port + Container */}
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Tech</span>
<span className="font-medium text-slate-800">{service.tech}</span>
</div>
{service.port && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Port</span>
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded">{service.port}</code>
</div>
)}
{service.url && (
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">URL</span>
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline truncate max-w-[180px]"
>
{service.url}
</a>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">Container</span>
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded truncate max-w-[180px]">{service.container}</code>
</div>
</div>
{/* DB Tables */}
{service.dbTables.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
DB-Tabellen ({service.dbTables.length})
</h4>
<div className="space-y-1">
{service.dbTables.map(table => (
<div
key={table}
className="text-sm bg-slate-100 rounded px-2 py-1"
>
<code className="text-slate-700 text-xs">{table}</code>
</div>
))}
</div>
</div>
)}
{/* RAG Collections */}
{service.ragCollections.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
RAG-Collections ({service.ragCollections.length})
</h4>
<div className="space-y-1">
{service.ragCollections.map(rag => (
<div
key={rag}
className="text-sm bg-green-50 rounded px-2 py-1"
>
<code className="text-green-700 text-xs">{rag}</code>
</div>
))}
</div>
</div>
)}
{/* API Endpoints */}
{service.apiEndpoints.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
API-Endpunkte ({service.apiEndpoints.length})
</h4>
<div className="space-y-1">
{service.apiEndpoints.map(ep => (
<div
key={ep}
className="text-sm bg-violet-50 rounded px-2 py-1"
>
<code className="text-violet-700 text-xs">{ep}</code>
</div>
))}
</div>
</div>
)}
{/* Dependencies */}
{service.dependsOn.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
Abhaengigkeiten
</h4>
<div className="space-y-1">
{service.dependsOn.map(depId => {
const dep = ARCH_SERVICES.find(s => s.id === depId)
return (
<div
key={depId}
className="text-sm text-slate-600 bg-slate-50 rounded px-2 py-1"
>
{dep?.name || depId}
</div>
)
})}
</div>
</div>
)}
{/* Open URL */}
{service.url && (
<button
onClick={() => window.open(service.url!, '_blank')}
className="w-full mt-2 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors"
>
Service oeffnen
</button>
)}
</div>
</div>
)
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
import { type LayerFilter } from './_layout'
import ArchHeader from './_components/ArchHeader'
import Toolbar from './_components/Toolbar'
import ArchCanvas from './_components/ArchCanvas'
import ServiceTable from './_components/ServiceTable'
export default function ArchitecturePage() {
const [selectedService, setSelectedService] = useState<ArchService | null>(null)
@@ -251,258 +42,6 @@ export default function ArchitecturePage() {
const allDbTables = useMemo(() => getAllDbTables(), [])
const allRagCollections = useMemo(() => getAllRagCollections(), [])
// =========================================================================
// Build Nodes + Edges
// =========================================================================
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
const nodes: Node[] = []
const edges: Edge[] = []
const visibleServices =
layerFilter === 'alle'
? ARCH_SERVICES
: ARCH_SERVICES.filter(s => s.layer === layerFilter)
const visibleIds = new Set(visibleServices.map(s => s.id))
// ── Service Nodes ──────────────────────────────────────────────────────
visibleServices.forEach(service => {
const layer = LAYERS[service.layer]
const pos = getServicePosition(service)
const isSelected = selectedService?.id === service.id
nodes.push({
id: service.id,
type: 'default',
position: pos,
data: {
label: (
<div className="text-center px-1">
<div className="font-medium text-xs leading-tight">
{service.nameShort}
</div>
<div className="text-[10px] opacity-70 mt-0.5">
{service.tech}
</div>
{service.port && (
<div className="text-[9px] opacity-50 mt-0.5">
:{service.port}
</div>
)}
</div>
),
},
style: {
background: isSelected ? layer.colorBorder : layer.colorBg,
color: isSelected ? 'white' : layer.colorText,
border: `2px solid ${layer.colorBorder}`,
borderRadius: '10px',
padding: '8px 4px',
minWidth: `${NODE_WIDTH}px`,
maxWidth: `${NODE_WIDTH}px`,
cursor: 'pointer',
boxShadow: isSelected
? `0 0 16px ${layer.colorBorder}`
: '0 1px 3px rgba(0,0,0,0.08)',
},
})
})
// ── Connection Edges ───────────────────────────────────────────────────
ARCH_EDGES.forEach(archEdge => {
if (visibleIds.has(archEdge.source) && visibleIds.has(archEdge.target)) {
const isHighlighted =
selectedService?.id === archEdge.source ||
selectedService?.id === archEdge.target
edges.push({
id: `e-${archEdge.source}-${archEdge.target}`,
source: archEdge.source,
target: archEdge.target,
type: 'smoothstep',
animated: isHighlighted,
label: archEdge.label,
labelStyle: { fontSize: 9, fill: isHighlighted ? '#7c3aed' : '#94a3b8' },
style: {
stroke: isHighlighted ? '#7c3aed' : '#94a3b8',
strokeWidth: isHighlighted ? 2.5 : 1.5,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: isHighlighted ? '#7c3aed' : '#94a3b8',
width: 14,
height: 14,
},
})
}
})
// ── DB Table Nodes ─────────────────────────────────────────────────────
if (showDb) {
const dbTablesInUse = new Set<string>()
visibleServices.forEach(s => s.dbTables.forEach(t => dbTablesInUse.add(t)))
let dbIdx = 0
dbTablesInUse.forEach(table => {
const nodeId = `db-${table}`
nodes.push({
id: nodeId,
type: 'default',
position: { x: -250, y: LANE_Y_START + dbIdx * 60 },
data: {
label: (
<div className="text-center">
<div className="font-medium text-[10px] leading-tight">{table}</div>
</div>
),
},
style: {
background: '#f1f5f9',
color: '#475569',
border: '1px solid #94a3b8',
borderRadius: '6px',
padding: '4px 6px',
fontSize: '10px',
minWidth: '140px',
},
})
visibleServices
.filter(s => s.dbTables.includes(table))
.forEach(svc => {
edges.push({
id: `e-db-${table}-${svc.id}`,
source: nodeId,
target: svc.id,
type: 'straight',
style: { stroke: '#94a3b8', strokeWidth: 1, strokeDasharray: '6 3' },
})
})
dbIdx++
})
}
// ── RAG Collection Nodes ───────────────────────────────────────────────
if (showRag) {
const ragInUse = new Set<string>()
visibleServices.forEach(s => s.ragCollections.forEach(r => ragInUse.add(r)))
let ragIdx = 0
ragInUse.forEach(collection => {
const nodeId = `rag-${collection}`
const rightX = 1200
nodes.push({
id: nodeId,
type: 'default',
position: { x: rightX, y: LANE_Y_START + ragIdx * 60 },
data: {
label: (
<div className="text-center">
<div className="font-medium text-[10px] leading-tight">
{collection.replace('bp_', '')}
</div>
</div>
),
},
style: {
background: '#dcfce7',
color: '#166534',
border: '1px solid #22c55e',
borderRadius: '6px',
padding: '4px 6px',
fontSize: '10px',
minWidth: '130px',
},
})
visibleServices
.filter(s => s.ragCollections.includes(collection))
.forEach(svc => {
edges.push({
id: `e-rag-${collection}-${svc.id}`,
source: nodeId,
target: svc.id,
type: 'straight',
style: { stroke: '#22c55e', strokeWidth: 1, strokeDasharray: '6 3' },
})
})
ragIdx++
})
}
// ── API Endpoint Nodes ─────────────────────────────────────────────────
if (showApis) {
visibleServices.forEach(svc => {
if (svc.apiEndpoints.length === 0) return
const svcPos = getServicePosition(svc)
svc.apiEndpoints.forEach((ep, idx) => {
const nodeId = `api-${svc.id}-${idx}`
nodes.push({
id: nodeId,
type: 'default',
position: { x: svcPos.x + NODE_WIDTH + 30, y: svcPos.y + idx * 32 },
data: {
label: (
<div className="text-[9px] font-mono leading-tight truncate">{ep}</div>
),
},
style: {
background: '#faf5ff',
color: '#7c3aed',
border: '1px solid #c4b5fd',
borderRadius: '4px',
padding: '2px 6px',
fontSize: '9px',
minWidth: '160px',
},
})
edges.push({
id: `e-api-${svc.id}-${idx}`,
source: svc.id,
target: nodeId,
type: 'straight',
style: { stroke: '#c4b5fd', strokeWidth: 1 },
})
})
})
}
return { nodes, edges }
}, [layerFilter, showDb, showRag, showApis, selectedService])
// =========================================================================
// React Flow State
// =========================================================================
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
useEffect(() => {
setNodes(initialNodes)
setEdges(initialEdges)
}, [initialNodes, initialEdges, setNodes, setEdges])
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
const service = ARCH_SERVICES.find(s => s.id === node.id)
if (service) {
setSelectedService(prev => (prev?.id === service.id ? null : service))
}
}, [])
const onPaneClick = useCallback(() => {
setSelectedService(null)
}, [])
// =========================================================================
// Stats
// =========================================================================
const stats = useMemo(() => {
return {
services: ARCH_SERVICES.length,
@@ -512,439 +51,41 @@ export default function ArchitecturePage() {
}
}, [allDbTables, allRagCollections])
// =========================================================================
// Render
// =========================================================================
const handleLayerFilter = useCallback((f: LayerFilter) => {
setLayerFilter(f)
setSelectedService(null)
}, [])
return (
<div className="space-y-4">
{/* Header */}
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-slate-700 flex items-center justify-center text-white">
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div>
<h2 className="text-xl font-bold text-slate-900">
Architektur-Uebersicht
</h2>
<p className="text-sm text-slate-500">
{stats.services} Services | {stats.dbTables} DB-Tabellen | {stats.ragCollections} RAG-Collections | {stats.edges} Verbindungen
</p>
</div>
</div>
</div>
<ArchHeader stats={stats} />
{/* Toolbar */}
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
{/* Layer Filter */}
<button
onClick={() => {
setLayerFilter('alle')
setSelectedService(null)
}}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
layerFilter === 'alle'
? 'bg-slate-800 text-white'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
Alle ({ARCH_SERVICES.length})
</button>
{LAYER_ORDER.map(layerId => {
const layer = LAYERS[layerId]
const count = ARCH_SERVICES.filter(s => s.layer === layerId).length
return (
<button
key={layerId}
onClick={() => {
setLayerFilter(layerFilter === layerId ? 'alle' : layerId)
setSelectedService(null)
}}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-all flex items-center gap-1.5"
style={{
background: layerFilter === layerId ? layer.colorBorder : layer.colorBg,
color: layerFilter === layerId ? 'white' : layer.colorText,
}}
>
<span
className="w-2.5 h-2.5 rounded-full"
style={{ background: layer.colorBorder }}
/>
{layer.name} ({count})
</button>
)
})}
<Toolbar
layerFilter={layerFilter}
showDb={showDb}
showRag={showRag}
showApis={showApis}
onLayerFilter={handleLayerFilter}
onToggleDb={() => setShowDb(v => !v)}
onToggleRag={() => setShowRag(v => !v)}
onToggleApis={() => setShowApis(v => !v)}
/>
{/* Separator */}
<div className="w-px h-6 bg-slate-200 mx-1" />
<ArchCanvas
layerFilter={layerFilter}
showDb={showDb}
showRag={showRag}
showApis={showApis}
selectedService={selectedService}
setSelectedService={setSelectedService}
/>
{/* Toggles */}
<button
onClick={() => setShowDb(!showDb)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
showDb
? 'bg-slate-700 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
DB-Tabellen
</button>
<button
onClick={() => setShowRag(!showRag)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
showRag
? 'bg-green-600 text-white'
: 'bg-green-50 text-green-700 hover:bg-green-100'
}`}
>
RAG-Collections
</button>
<button
onClick={() => setShowApis(!showApis)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
showApis
? 'bg-violet-600 text-white'
: 'bg-violet-50 text-violet-700 hover:bg-violet-100'
}`}
>
API-Endpunkte
</button>
</div>
</div>
{/* Flow Canvas + Detail Panel */}
<div className="flex bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '700px' }}>
{/* Canvas */}
<div className="flex-1 relative">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
fitView
fitViewOptions={{ padding: 0.15 }}
attributionPosition="bottom-left"
>
<Controls />
<MiniMap
nodeColor={node => {
if (node.id.startsWith('db-')) return '#94a3b8'
if (node.id.startsWith('rag-')) return '#22c55e'
if (node.id.startsWith('api-')) return '#c4b5fd'
const svc = ARCH_SERVICES.find(s => s.id === node.id)
return svc ? LAYERS[svc.layer].colorBorder : '#94a3b8'
}}
maskColor="rgba(0,0,0,0.08)"
/>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
{/* Legende */}
<Panel
position="bottom-right"
className="bg-white/95 p-3 rounded-lg shadow-lg text-xs"
>
<div className="font-medium text-slate-700 mb-2">Legende</div>
<div className="space-y-1">
{LAYER_ORDER.map(layerId => {
const layer = LAYERS[layerId]
return (
<div key={layerId} className="flex items-center gap-2">
<span
className="w-3 h-3 rounded"
style={{
background: layer.colorBg,
border: `1px solid ${layer.colorBorder}`,
}}
/>
<span className="text-slate-600">{layer.name}</span>
</div>
)
})}
<div className="border-t border-slate-200 my-1.5 pt-1.5">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-slate-200 border border-slate-400" />
<span className="text-slate-500">DB-Tabelle</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="w-3 h-3 rounded bg-green-100 border border-green-500" />
<span className="text-slate-500">RAG-Collection</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="w-3 h-3 rounded bg-violet-100 border border-violet-400" />
<span className="text-slate-500">API-Endpunkt</span>
</div>
</div>
</div>
</Panel>
{/* Swim-Lane Labels */}
{layerFilter === 'alle' && (
<Panel position="top-left" className="pointer-events-none">
<div className="space-y-1">
{LAYER_ORDER.map(layerId => {
const layer = LAYERS[layerId]
return (
<div
key={layerId}
className="px-3 py-1 rounded text-xs font-medium opacity-50"
style={{
background: layer.colorBg,
color: layer.colorText,
border: `1px solid ${layer.colorBorder}`,
}}
>
{layer.name}
</div>
)
})}
</div>
</Panel>
)}
</ReactFlow>
</div>
{/* Detail Panel */}
{selectedService && (
<DetailPanel
service={selectedService}
onClose={() => setSelectedService(null)}
/>
)}
</div>
{/* Service Table (aufklappbar) */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
<div className="px-4 py-3 bg-slate-50 border-b">
<h3 className="font-medium text-slate-700">
Alle Services ({
layerFilter === 'alle'
? ARCH_SERVICES.length
: `${ARCH_SERVICES.filter(s => s.layer === layerFilter).length} / ${ARCH_SERVICES.length}`
})
</h3>
</div>
<div className="divide-y max-h-[600px] overflow-y-auto">
{ARCH_SERVICES.filter(
s => layerFilter === 'alle' || s.layer === layerFilter
).map(service => {
const layer = LAYERS[service.layer]
const isExpanded = expandedServices.has(service.id)
return (
<div key={service.id}>
{/* Row Header */}
<button
onClick={() => toggleExpanded(service.id)}
className={`w-full flex items-center gap-3 p-3 text-left transition-colors ${
isExpanded
? 'bg-purple-50'
: 'hover:bg-slate-50'
}`}
>
{/* Chevron */}
<svg
className={`w-4 h-4 text-slate-400 shrink-0 transition-transform duration-200 ${
isExpanded ? 'rotate-90' : ''
}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0"
style={{ background: layer.colorBg, color: layer.colorText }}
>
{service.port ? `:${service.port}` : '--'}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 text-sm">
{service.name}
</div>
<div className="text-xs text-slate-500 flex items-center gap-2 mt-0.5">
<span className="text-slate-400">{service.tech}</span>
{service.dbTables.length > 0 && (
<span className="text-slate-400">
DB: {service.dbTables.length}
</span>
)}
{service.ragCollections.length > 0 && (
<span className="text-green-600">
RAG: {service.ragCollections.length}
</span>
)}
{service.apiEndpoints.length > 0 && (
<span className="text-violet-600">
API: {service.apiEndpoints.length}
</span>
)}
</div>
</div>
<code className="text-[10px] text-slate-400 shrink-0 hidden sm:block">
{service.container}
</code>
<span
className="px-2 py-0.5 rounded text-[10px] font-medium shrink-0"
style={{ background: layer.colorBg, color: layer.colorText }}
>
{layer.name}
</span>
</button>
{/* Expanded Detail */}
{isExpanded && (
<div className="px-4 pb-4 pt-1 bg-slate-50/50 border-t border-slate-100">
{/* Beschreibung */}
<p className="text-sm text-slate-700 leading-relaxed">
{service.description}
</p>
<p className="text-xs text-slate-500 leading-relaxed mt-1 mb-3">
{service.descriptionLong}
</p>
{/* Info Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
<div className="text-[10px] font-semibold text-slate-400 uppercase">Tech</div>
<div className="text-sm font-medium text-slate-800 mt-0.5">{service.tech}</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
<div className="text-[10px] font-semibold text-slate-400 uppercase">Port</div>
<div className="text-sm font-medium text-slate-800 mt-0.5">
{service.port ? `:${service.port}` : 'Intern'}
</div>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
<div className="text-[10px] font-semibold text-slate-400 uppercase">Container</div>
<div className="text-xs font-mono text-slate-700 mt-0.5 truncate">{service.container}</div>
</div>
{service.url && (
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
<div className="text-[10px] font-semibold text-slate-400 uppercase">URL</div>
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline mt-0.5 block truncate"
>
{service.url}
</a>
</div>
)}
</div>
{/* Sections */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{/* DB Tables */}
{service.dbTables.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-3">
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
DB-Tabellen ({service.dbTables.length})
</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{service.dbTables.map(table => (
<div key={table} className="bg-slate-50 rounded px-2 py-1">
<code className="text-xs text-slate-700">{table}</code>
</div>
))}
</div>
</div>
)}
{/* RAG Collections */}
{service.ragCollections.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-3">
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
RAG-Collections ({service.ragCollections.length})
</h4>
<div className="space-y-1">
{service.ragCollections.map(rag => (
<div key={rag} className="bg-green-50 rounded px-2 py-1">
<code className="text-xs text-green-700">{rag}</code>
</div>
))}
</div>
</div>
)}
{/* API Endpoints */}
{service.apiEndpoints.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-3">
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
API-Endpunkte ({service.apiEndpoints.length})
</h4>
<div className="space-y-1 max-h-40 overflow-y-auto">
{service.apiEndpoints.map(ep => (
<div key={ep} className="bg-violet-50 rounded px-2 py-1">
<code className="text-xs text-violet-700">{ep}</code>
</div>
))}
</div>
</div>
)}
{/* Dependencies */}
{service.dependsOn.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 p-3">
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
Abhaengigkeiten ({service.dependsOn.length})
</h4>
<div className="space-y-1">
{service.dependsOn.map(depId => {
const dep = ARCH_SERVICES.find(s => s.id === depId)
const depLayer = dep ? LAYERS[dep.layer] : null
return (
<div key={depId} className="flex items-center gap-2 bg-slate-50 rounded px-2 py-1">
{depLayer && (
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ background: depLayer.colorBorder }}
/>
)}
<span className="text-xs text-slate-700">{dep?.name || depId}</span>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Open in Graph + URL */}
<div className="flex items-center gap-2 mt-3">
<button
onClick={() => {
setSelectedService(service)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium hover:bg-slate-200 transition-colors"
>
Im Graph markieren
</button>
{service.url && (
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 transition-colors"
>
Service oeffnen
</a>
)}
</div>
</div>
)}
</div>
)
})}
</div>
</div>
<ServiceTable
layerFilter={layerFilter}
expandedServices={expandedServices}
onToggleExpanded={toggleExpanded}
onMarkInGraph={setSelectedService}
/>
</div>
)
}

View File

@@ -0,0 +1,119 @@
'use client'
import { useState } from 'react'
import { DisplayChecklistItem, DisplayStatus } from './types'
interface ChecklistItemCardProps {
item: DisplayChecklistItem
onStatusChange: (status: DisplayStatus) => void
onNotesChange: (notes: string) => void
onAddEvidence: () => void
}
export function ChecklistItemCard({
item,
onStatusChange,
onNotesChange,
onAddEvidence,
}: ChecklistItemCardProps) {
const [showNotes, setShowNotes] = useState(false)
const statusColors = {
compliant: 'bg-green-100 text-green-700 border-green-300',
'non-compliant': 'bg-red-100 text-red-700 border-red-300',
partial: 'bg-yellow-100 text-yellow-700 border-yellow-300',
'not-reviewed': 'bg-gray-100 text-gray-500 border-gray-300',
}
const priorityColors = {
critical: 'bg-red-100 text-red-700',
high: 'bg-orange-100 text-orange-700',
medium: 'bg-yellow-100 text-yellow-700',
low: 'bg-green-100 text-green-700',
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
item.status === 'non-compliant' ? 'border-red-200' :
item.status === 'partial' ? 'border-yellow-200' :
item.status === 'compliant' ? 'border-green-200' : 'border-gray-200'
}`}>
<div className="flex items-start gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500">{item.category}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[item.priority]}`}>
{item.priority === 'critical' ? 'Kritisch' :
item.priority === 'high' ? 'Hoch' :
item.priority === 'medium' ? 'Mittel' : 'Niedrig'}
</span>
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{item.requirementId}
</span>
</div>
<p className="text-gray-900 font-medium">{item.question}</p>
</div>
<select
value={item.status}
onChange={(e) => onStatusChange(e.target.value as DisplayStatus)}
className={`px-3 py-2 rounded-lg border text-sm font-medium ${statusColors[item.status]}`}
>
<option value="not-reviewed">Nicht geprueft</option>
<option value="compliant">Konform</option>
<option value="partial">Teilweise</option>
<option value="non-compliant">Nicht konform</option>
</select>
</div>
{item.notes && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
{item.notes}
</div>
)}
{item.evidence.length > 0 && (
<div className="mt-3 flex items-center gap-2">
<span className="text-sm text-gray-500">Nachweise:</span>
{item.evidence.map(ev => (
<span key={ev} className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded">
{ev}
</span>
))}
</div>
)}
{item.verifiedBy && item.verifiedAt && (
<div className="mt-3 text-sm text-gray-500">
Geprueft von {item.verifiedBy} am {item.verifiedAt.toLocaleDateString('de-DE')}
</div>
)}
<div className="mt-3 flex items-center gap-2">
<button
onClick={() => setShowNotes(!showNotes)}
className="text-sm text-purple-600 hover:text-purple-700"
>
{showNotes ? 'Notizen ausblenden' : 'Notizen bearbeiten'}
</button>
<button
onClick={onAddEvidence}
className="text-sm text-gray-500 hover:text-gray-700"
>
Nachweis hinzufuegen
</button>
</div>
{showNotes && (
<div className="mt-3">
<textarea
value={item.notes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Notizen hinzufuegen..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,17 @@
'use client'
export function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-center gap-2 mb-3">
<div className="h-4 w-24 bg-gray-200 rounded" />
<div className="h-4 w-16 bg-gray-200 rounded-full" />
</div>
<div className="h-5 w-full bg-gray-200 rounded" />
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { useRouter } from 'next/navigation'
import { PastSession } from './types'
interface SessionHistoryProps {
pastSessions: PastSession[]
activeSessionId: string | null
}
export function SessionHistory({ pastSessions, activeSessionId }: SessionHistoryProps) {
const router = useRouter()
if (pastSessions.length === 0) return null
const statusBadge: Record<string, string> = {
draft: 'bg-slate-100 text-slate-700',
in_progress: 'bg-blue-100 text-blue-700',
completed: 'bg-green-100 text-green-700',
archived: 'bg-purple-100 text-purple-700',
}
const statusLabel: Record<string, string> = {
draft: 'Entwurf',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen',
archived: 'Archiviert',
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Vergangene Audit-Sessions</h3>
<div className="space-y-3">
{pastSessions
.filter(s => s.id !== activeSessionId)
.map(session => (
<div
key={session.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"
onClick={() => router.push(`/sdk/audit-report/${session.id}`)}
>
<div className="flex items-center gap-3">
<span className={`px-2 py-0.5 text-xs rounded-full ${statusBadge[session.status] || ''}`}>
{statusLabel[session.status] || session.status}
</span>
<span className="text-sm font-medium text-gray-900">{session.name}</span>
<span className="text-xs text-gray-500">
{new Date(session.created_at).toLocaleDateString('de-DE')}
</span>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-500">
{session.completed_items}/{session.total_items} Punkte
</span>
<span className={`font-medium ${
session.completion_percentage >= 80 ? 'text-green-600' :
session.completion_percentage >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>
{session.completion_percentage}%
</span>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { ChecklistTemplate } from './types'
// =============================================================================
// FALLBACK TEMPLATES
// =============================================================================
export const checklistTemplates: ChecklistTemplate[] = [
{
id: 'chk-vvt-001',
requirementId: 'req-gdpr-30',
question: 'Ist ein Verzeichnis von Verarbeitungstaetigkeiten (VVT) vorhanden und aktuell?',
category: 'Dokumentation',
priority: 'critical',
},
{
id: 'chk-dse-001',
requirementId: 'req-gdpr-13',
question: 'Sind Datenschutzhinweise fuer alle Verarbeitungen verfuegbar?',
category: 'Transparenz',
priority: 'high',
},
{
id: 'chk-consent-001',
requirementId: 'req-gdpr-6',
question: 'Werden Einwilligungen ordnungsgemaess eingeholt und dokumentiert?',
category: 'Einwilligung',
priority: 'high',
},
{
id: 'chk-dsr-001',
requirementId: 'req-gdpr-15',
question: 'Ist ein Prozess fuer Betroffenenrechte implementiert?',
category: 'Betroffenenrechte',
priority: 'critical',
},
{
id: 'chk-avv-001',
requirementId: 'req-gdpr-28',
question: 'Sind Auftragsverarbeitungsvertraege (AVV) mit allen Dienstleistern abgeschlossen?',
category: 'Auftragsverarbeitung',
priority: 'critical',
},
{
id: 'chk-dsfa-001',
requirementId: 'req-gdpr-35',
question: 'Wird eine DSFA fuer Hochrisiko-Verarbeitungen durchgefuehrt?',
category: 'Risikobewertung',
priority: 'high',
},
{
id: 'chk-tom-001',
requirementId: 'req-gdpr-32',
question: 'Sind technische und organisatorische Massnahmen dokumentiert?',
category: 'TOMs',
priority: 'high',
},
{
id: 'chk-incident-001',
requirementId: 'req-gdpr-33',
question: 'Gibt es einen Incident-Response-Prozess fuer Datenpannen?',
category: 'Incident Response',
priority: 'critical',
},
{
id: 'chk-ai-001',
requirementId: 'req-ai-act-9',
question: 'Ist das KI-System nach EU AI Act klassifiziert?',
category: 'AI Act',
priority: 'high',
},
{
id: 'chk-ai-002',
requirementId: 'req-ai-act-13',
question: 'Sind Transparenzanforderungen fuer KI-Systeme erfuellt?',
category: 'AI Act',
priority: 'high',
},
]

View File

@@ -0,0 +1,26 @@
import { ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
import { DisplayStatus } from './types'
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
export function mapSDKStatusToDisplay(status: SDKChecklistItem['status']): DisplayStatus {
switch (status) {
case 'PASSED': return 'compliant'
case 'FAILED': return 'non-compliant'
case 'NOT_APPLICABLE': return 'partial'
case 'PENDING':
default: return 'not-reviewed'
}
}
export function mapDisplayStatusToSDK(status: DisplayStatus): SDKChecklistItem['status'] {
switch (status) {
case 'compliant': return 'PASSED'
case 'non-compliant': return 'FAILED'
case 'partial': return 'NOT_APPLICABLE'
case 'not-reviewed':
default: return 'PENDING'
}
}

View File

@@ -0,0 +1,39 @@
// =============================================================================
// TYPES
// =============================================================================
export type DisplayStatus = 'compliant' | 'non-compliant' | 'partial' | 'not-reviewed'
export type DisplayPriority = 'critical' | 'high' | 'medium' | 'low'
export interface DisplayChecklistItem {
id: string
requirementId: string
question: string
category: string
status: DisplayStatus
notes: string
evidence: string[]
priority: DisplayPriority
verifiedBy: string | null
verifiedAt: Date | null
}
export interface PastSession {
id: string
name: string
status: string
auditor_name: string
created_at: string
completed_at?: string
completion_percentage: number
total_items: number
completed_items: number
}
export interface ChecklistTemplate {
id: string
requirementId: string
question: string
category: string
priority: DisplayPriority
}

View File

@@ -0,0 +1,266 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSDK, ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
import { PastSession } from '../_components/types'
import { checklistTemplates } from '../_components/checklistTemplates'
import { mapSDKStatusToDisplay, mapDisplayStatusToSDK } from '../_components/statusHelpers'
import { DisplayStatus, DisplayChecklistItem } from '../_components/types'
export function useAuditChecklist() {
const { state, dispatch } = useSDK()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const notesTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
const [pastSessions, setPastSessions] = useState<PastSession[]>([])
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
const [generatingPdf, setGeneratingPdf] = useState(false)
useEffect(() => {
const fetchChecklist = async () => {
try {
setLoading(true)
const sessionsRes = await fetch('/api/sdk/v1/compliance/audit/sessions?status=in_progress')
if (sessionsRes.ok) {
const sessionsData = await sessionsRes.json()
const sessions = sessionsData.sessions || sessionsData
if (Array.isArray(sessions) && sessions.length > 0) {
const session = sessions[0]
setActiveSessionId(session.id)
const checklistRes = await fetch(`/api/sdk/v1/compliance/audit/checklist/${session.id}`)
if (checklistRes.ok) {
const checklistData = await checklistRes.json()
const items = checklistData.items || checklistData.checklist || checklistData
if (Array.isArray(items) && items.length > 0) {
const mapped: SDKChecklistItem[] = items.map((item: Record<string, unknown>) => ({
id: (item.id || item.requirement_id || '') as string,
requirementId: (item.requirement_id || '') as string,
title: (item.title || item.question || '') as string,
description: (item.category || item.description || '') as string,
status: ((item.status || 'PENDING') as string).toUpperCase() as SDKChecklistItem['status'],
notes: (item.notes || item.auditor_notes || '') as string,
verifiedBy: (item.verified_by || item.signed_off_by || null) as string | null,
verifiedAt: item.verified_at || item.signed_off_at ? new Date((item.verified_at || item.signed_off_at) as string) : null,
}))
dispatch({ type: 'SET_STATE', payload: { checklist: mapped } })
setError(null)
return
}
}
}
}
loadFromTemplates()
} catch {
loadFromTemplates()
} finally {
setLoading(false)
}
}
const loadFromTemplates = () => {
if (state.checklist.length > 0) return
const templatesToLoad = state.requirements.length > 0
? checklistTemplates.filter(t =>
state.requirements.some(r => r.id === t.requirementId)
)
: checklistTemplates
const items: SDKChecklistItem[] = templatesToLoad.map(template => ({
id: template.id,
requirementId: template.requirementId,
title: template.question,
description: template.category,
status: 'PENDING',
notes: '',
verifiedBy: null,
verifiedAt: null,
}))
if (items.length > 0) {
dispatch({ type: 'SET_STATE', payload: { checklist: items } })
}
}
fetchChecklist()
const fetchAllSessions = async () => {
try {
const res = await fetch('/api/sdk/v1/compliance/audit/sessions')
if (res.ok) {
const data = await res.json()
const sessions = data.sessions || []
setPastSessions(sessions)
}
} catch {
// Silently fail
}
}
fetchAllSessions()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const displayItems: DisplayChecklistItem[] = state.checklist.map(item => {
const template = checklistTemplates.find(t => t.id === item.id)
return {
id: item.id,
requirementId: item.requirementId,
question: item.title,
category: item.description || template?.category || 'Allgemein',
status: mapSDKStatusToDisplay(item.status),
notes: item.notes,
evidence: [],
priority: template?.priority || 'medium',
verifiedBy: item.verifiedBy,
verifiedAt: item.verifiedAt,
}
})
const handleStatusChange = async (itemId: string, status: DisplayStatus) => {
const sdkStatus = mapDisplayStatusToSDK(status)
const updatedChecklist = state.checklist.map(item =>
item.id === itemId
? {
...item,
status: sdkStatus,
verifiedBy: status !== 'not-reviewed' ? 'Aktueller Benutzer' : null,
verifiedAt: status !== 'not-reviewed' ? new Date() : null,
}
: item
)
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
if (activeSessionId) {
try {
const item = state.checklist.find(i => i.id === itemId)
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: sdkStatus === 'PASSED' ? 'compliant' : sdkStatus === 'FAILED' ? 'non_compliant' : sdkStatus === 'NOT_APPLICABLE' ? 'partially_compliant' : 'not_assessed',
auditor_notes: item?.notes || '',
}),
})
} catch {
// Silently fail
}
}
}
const handleNotesChange = useCallback((itemId: string, notes: string) => {
const updatedChecklist = state.checklist.map(item =>
item.id === itemId ? { ...item, notes } : item
)
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
if (notesTimerRef.current[itemId]) {
clearTimeout(notesTimerRef.current[itemId])
}
notesTimerRef.current[itemId] = setTimeout(async () => {
if (activeSessionId) {
const item = state.checklist.find(i => i.id === itemId)
try {
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes }),
})
} catch {
// Silently fail
}
}
}, 1000)
}, [state.checklist, activeSessionId, dispatch])
const handleExport = () => {
const exportData = displayItems.map(item => ({
id: item.id,
requirementId: item.requirementId,
question: item.question,
category: item.category,
status: item.status,
notes: item.notes,
priority: item.priority,
verifiedBy: item.verifiedBy,
verifiedAt: item.verifiedAt?.toISOString() || null,
}))
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-checklist-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const handlePdfDownload = async () => {
if (!activeSessionId) {
setError('Kein aktives Audit vorhanden. Erstellen Sie zuerst eine Checkliste.')
return
}
setGeneratingPdf(true)
setError(null)
try {
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${activeSessionId}/report/pdf?language=${pdfLanguage}`)
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-checklist-${activeSessionId}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
} finally {
setGeneratingPdf(false)
}
}
const handleNewChecklist = async () => {
try {
setError(null)
const res = await fetch('/api/sdk/v1/compliance/audit/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `Compliance Audit ${new Date().toLocaleDateString('de-DE')}`,
auditor_name: 'Aktueller Benutzer',
regulation_codes: ['GDPR'],
}),
})
if (res.ok) {
window.location.reload()
} else {
setError('Fehler beim Erstellen der neuen Checkliste')
}
} catch {
setError('Verbindung zum Backend fehlgeschlagen')
}
}
return {
state,
loading,
error,
setError,
activeSessionId,
pastSessions,
pdfLanguage,
setPdfLanguage,
generatingPdf,
displayItems,
handleStatusChange,
handleNotesChange,
handleExport,
handlePdfDownload,
handleNewChecklist,
}
}

View File

@@ -1,408 +1,33 @@
'use client'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK, ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// TYPES
// =============================================================================
type DisplayStatus = 'compliant' | 'non-compliant' | 'partial' | 'not-reviewed'
type DisplayPriority = 'critical' | 'high' | 'medium' | 'low'
interface DisplayChecklistItem {
id: string
requirementId: string
question: string
category: string
status: DisplayStatus
notes: string
evidence: string[]
priority: DisplayPriority
verifiedBy: string | null
verifiedAt: Date | null
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function mapSDKStatusToDisplay(status: SDKChecklistItem['status']): DisplayStatus {
switch (status) {
case 'PASSED': return 'compliant'
case 'FAILED': return 'non-compliant'
case 'NOT_APPLICABLE': return 'partial'
case 'PENDING':
default: return 'not-reviewed'
}
}
function mapDisplayStatusToSDK(status: DisplayStatus): SDKChecklistItem['status'] {
switch (status) {
case 'compliant': return 'PASSED'
case 'non-compliant': return 'FAILED'
case 'partial': return 'NOT_APPLICABLE'
case 'not-reviewed':
default: return 'PENDING'
}
}
// =============================================================================
// FALLBACK TEMPLATES
// =============================================================================
interface ChecklistTemplate {
id: string
requirementId: string
question: string
category: string
priority: DisplayPriority
}
const checklistTemplates: ChecklistTemplate[] = [
{
id: 'chk-vvt-001',
requirementId: 'req-gdpr-30',
question: 'Ist ein Verzeichnis von Verarbeitungstaetigkeiten (VVT) vorhanden und aktuell?',
category: 'Dokumentation',
priority: 'critical',
},
{
id: 'chk-dse-001',
requirementId: 'req-gdpr-13',
question: 'Sind Datenschutzhinweise fuer alle Verarbeitungen verfuegbar?',
category: 'Transparenz',
priority: 'high',
},
{
id: 'chk-consent-001',
requirementId: 'req-gdpr-6',
question: 'Werden Einwilligungen ordnungsgemaess eingeholt und dokumentiert?',
category: 'Einwilligung',
priority: 'high',
},
{
id: 'chk-dsr-001',
requirementId: 'req-gdpr-15',
question: 'Ist ein Prozess fuer Betroffenenrechte implementiert?',
category: 'Betroffenenrechte',
priority: 'critical',
},
{
id: 'chk-avv-001',
requirementId: 'req-gdpr-28',
question: 'Sind Auftragsverarbeitungsvertraege (AVV) mit allen Dienstleistern abgeschlossen?',
category: 'Auftragsverarbeitung',
priority: 'critical',
},
{
id: 'chk-dsfa-001',
requirementId: 'req-gdpr-35',
question: 'Wird eine DSFA fuer Hochrisiko-Verarbeitungen durchgefuehrt?',
category: 'Risikobewertung',
priority: 'high',
},
{
id: 'chk-tom-001',
requirementId: 'req-gdpr-32',
question: 'Sind technische und organisatorische Massnahmen dokumentiert?',
category: 'TOMs',
priority: 'high',
},
{
id: 'chk-incident-001',
requirementId: 'req-gdpr-33',
question: 'Gibt es einen Incident-Response-Prozess fuer Datenpannen?',
category: 'Incident Response',
priority: 'critical',
},
{
id: 'chk-ai-001',
requirementId: 'req-ai-act-9',
question: 'Ist das KI-System nach EU AI Act klassifiziert?',
category: 'AI Act',
priority: 'high',
},
{
id: 'chk-ai-002',
requirementId: 'req-ai-act-13',
question: 'Sind Transparenzanforderungen fuer KI-Systeme erfuellt?',
category: 'AI Act',
priority: 'high',
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
function ChecklistItemCard({
item,
onStatusChange,
onNotesChange,
onAddEvidence,
}: {
item: DisplayChecklistItem
onStatusChange: (status: DisplayStatus) => void
onNotesChange: (notes: string) => void
onAddEvidence: () => void
}) {
const [showNotes, setShowNotes] = useState(false)
const statusColors = {
compliant: 'bg-green-100 text-green-700 border-green-300',
'non-compliant': 'bg-red-100 text-red-700 border-red-300',
partial: 'bg-yellow-100 text-yellow-700 border-yellow-300',
'not-reviewed': 'bg-gray-100 text-gray-500 border-gray-300',
}
const priorityColors = {
critical: 'bg-red-100 text-red-700',
high: 'bg-orange-100 text-orange-700',
medium: 'bg-yellow-100 text-yellow-700',
low: 'bg-green-100 text-green-700',
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
item.status === 'non-compliant' ? 'border-red-200' :
item.status === 'partial' ? 'border-yellow-200' :
item.status === 'compliant' ? 'border-green-200' : 'border-gray-200'
}`}>
<div className="flex items-start gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500">{item.category}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[item.priority]}`}>
{item.priority === 'critical' ? 'Kritisch' :
item.priority === 'high' ? 'Hoch' :
item.priority === 'medium' ? 'Mittel' : 'Niedrig'}
</span>
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{item.requirementId}
</span>
</div>
<p className="text-gray-900 font-medium">{item.question}</p>
</div>
<select
value={item.status}
onChange={(e) => onStatusChange(e.target.value as DisplayStatus)}
className={`px-3 py-2 rounded-lg border text-sm font-medium ${statusColors[item.status]}`}
>
<option value="not-reviewed">Nicht geprueft</option>
<option value="compliant">Konform</option>
<option value="partial">Teilweise</option>
<option value="non-compliant">Nicht konform</option>
</select>
</div>
{item.notes && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
{item.notes}
</div>
)}
{item.evidence.length > 0 && (
<div className="mt-3 flex items-center gap-2">
<span className="text-sm text-gray-500">Nachweise:</span>
{item.evidence.map(ev => (
<span key={ev} className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded">
{ev}
</span>
))}
</div>
)}
{item.verifiedBy && item.verifiedAt && (
<div className="mt-3 text-sm text-gray-500">
Geprueft von {item.verifiedBy} am {item.verifiedAt.toLocaleDateString('de-DE')}
</div>
)}
<div className="mt-3 flex items-center gap-2">
<button
onClick={() => setShowNotes(!showNotes)}
className="text-sm text-purple-600 hover:text-purple-700"
>
{showNotes ? 'Notizen ausblenden' : 'Notizen bearbeiten'}
</button>
<button
onClick={onAddEvidence}
className="text-sm text-gray-500 hover:text-gray-700"
>
Nachweis hinzufuegen
</button>
</div>
{showNotes && (
<div className="mt-3">
<textarea
value={item.notes}
onChange={(e) => onNotesChange(e.target.value)}
placeholder="Notizen hinzufuegen..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
/>
</div>
)}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-center gap-2 mb-3">
<div className="h-4 w-24 bg-gray-200 rounded" />
<div className="h-4 w-16 bg-gray-200 rounded-full" />
</div>
<div className="h-5 w-full bg-gray-200 rounded" />
</div>
))}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
interface PastSession {
id: string
name: string
status: string
auditor_name: string
created_at: string
completed_at?: string
completion_percentage: number
total_items: number
completed_items: number
}
import { useAuditChecklist } from './_hooks/useAuditChecklist'
import { ChecklistItemCard } from './_components/ChecklistItemCard'
import { LoadingSkeleton } from './_components/LoadingSkeleton'
import { SessionHistory } from './_components/SessionHistory'
export default function AuditChecklistPage() {
const { state, dispatch } = useSDK()
const router = useRouter()
const [filter, setFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const notesTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
const [pastSessions, setPastSessions] = useState<PastSession[]>([])
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
const [generatingPdf, setGeneratingPdf] = useState(false)
// Fetch checklist from backend on mount
useEffect(() => {
const fetchChecklist = async () => {
try {
setLoading(true)
// First, try to find an active audit session
const sessionsRes = await fetch('/api/sdk/v1/compliance/audit/sessions?status=in_progress')
if (sessionsRes.ok) {
const sessionsData = await sessionsRes.json()
const sessions = sessionsData.sessions || sessionsData
if (Array.isArray(sessions) && sessions.length > 0) {
const session = sessions[0]
setActiveSessionId(session.id)
// Fetch checklist items for this session
const checklistRes = await fetch(`/api/sdk/v1/compliance/audit/checklist/${session.id}`)
if (checklistRes.ok) {
const checklistData = await checklistRes.json()
const items = checklistData.items || checklistData.checklist || checklistData
if (Array.isArray(items) && items.length > 0) {
const mapped: SDKChecklistItem[] = items.map((item: Record<string, unknown>) => ({
id: (item.id || item.requirement_id || '') as string,
requirementId: (item.requirement_id || '') as string,
title: (item.title || item.question || '') as string,
description: (item.category || item.description || '') as string,
status: ((item.status || 'PENDING') as string).toUpperCase() as SDKChecklistItem['status'],
notes: (item.notes || item.auditor_notes || '') as string,
verifiedBy: (item.verified_by || item.signed_off_by || null) as string | null,
verifiedAt: item.verified_at || item.signed_off_at ? new Date((item.verified_at || item.signed_off_at) as string) : null,
}))
dispatch({ type: 'SET_STATE', payload: { checklist: mapped } })
setError(null)
return
}
}
}
}
// Fallback: load from templates
loadFromTemplates()
} catch {
loadFromTemplates()
} finally {
setLoading(false)
}
}
const loadFromTemplates = () => {
if (state.checklist.length > 0) return
const templatesToLoad = state.requirements.length > 0
? checklistTemplates.filter(t =>
state.requirements.some(r => r.id === t.requirementId)
)
: checklistTemplates
const items: SDKChecklistItem[] = templatesToLoad.map(template => ({
id: template.id,
requirementId: template.requirementId,
title: template.question,
description: template.category,
status: 'PENDING',
notes: '',
verifiedBy: null,
verifiedAt: null,
}))
if (items.length > 0) {
dispatch({ type: 'SET_STATE', payload: { checklist: items } })
}
}
fetchChecklist()
// Also fetch all sessions for history view
const fetchAllSessions = async () => {
try {
const res = await fetch('/api/sdk/v1/compliance/audit/sessions')
if (res.ok) {
const data = await res.json()
const sessions = data.sessions || []
setPastSessions(sessions)
}
} catch {
// Silently fail
}
}
fetchAllSessions()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Convert SDK checklist items to display items
const displayItems: DisplayChecklistItem[] = state.checklist.map(item => {
const template = checklistTemplates.find(t => t.id === item.id)
return {
id: item.id,
requirementId: item.requirementId,
question: item.title,
category: item.description || template?.category || 'Allgemein',
status: mapSDKStatusToDisplay(item.status),
notes: item.notes,
evidence: [],
priority: template?.priority || 'medium',
verifiedBy: item.verifiedBy,
verifiedAt: item.verifiedAt,
}
})
const {
state,
loading,
error,
setError,
activeSessionId,
pastSessions,
pdfLanguage,
setPdfLanguage,
generatingPdf,
displayItems,
handleStatusChange,
handleNotesChange,
handleExport,
handlePdfDownload,
handleNewChecklist,
} = useAuditChecklist()
const filteredItems = filter === 'all'
? displayItems
@@ -417,141 +42,10 @@ export default function AuditChecklistPage() {
? Math.round(((compliantCount + partialCount * 0.5) / displayItems.length) * 100)
: 0
const handleStatusChange = async (itemId: string, status: DisplayStatus) => {
const sdkStatus = mapDisplayStatusToSDK(status)
const updatedChecklist = state.checklist.map(item =>
item.id === itemId
? {
...item,
status: sdkStatus,
verifiedBy: status !== 'not-reviewed' ? 'Aktueller Benutzer' : null,
verifiedAt: status !== 'not-reviewed' ? new Date() : null,
}
: item
)
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
// Persist to backend if we have an active session
if (activeSessionId) {
try {
const item = state.checklist.find(i => i.id === itemId)
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: sdkStatus === 'PASSED' ? 'compliant' : sdkStatus === 'FAILED' ? 'non_compliant' : sdkStatus === 'NOT_APPLICABLE' ? 'partially_compliant' : 'not_assessed',
auditor_notes: item?.notes || '',
}),
})
} catch {
// Silently fail
}
}
}
const handleNotesChange = useCallback((itemId: string, notes: string) => {
const updatedChecklist = state.checklist.map(item =>
item.id === itemId ? { ...item, notes } : item
)
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
// Debounced persistence to backend
if (notesTimerRef.current[itemId]) {
clearTimeout(notesTimerRef.current[itemId])
}
notesTimerRef.current[itemId] = setTimeout(async () => {
if (activeSessionId) {
const item = state.checklist.find(i => i.id === itemId)
try {
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notes }),
})
} catch {
// Silently fail
}
}
}, 1000)
}, [state.checklist, activeSessionId, dispatch])
const handleExport = () => {
const exportData = displayItems.map(item => ({
id: item.id,
requirementId: item.requirementId,
question: item.question,
category: item.category,
status: item.status,
notes: item.notes,
priority: item.priority,
verifiedBy: item.verifiedBy,
verifiedAt: item.verifiedAt?.toISOString() || null,
}))
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-checklist-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const handlePdfDownload = async () => {
if (!activeSessionId) {
setError('Kein aktives Audit vorhanden. Erstellen Sie zuerst eine Checkliste.')
return
}
setGeneratingPdf(true)
setError(null)
try {
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${activeSessionId}/report/pdf?language=${pdfLanguage}`)
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-checklist-${activeSessionId}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
} finally {
setGeneratingPdf(false)
}
}
const handleNewChecklist = async () => {
try {
setError(null)
const res = await fetch('/api/sdk/v1/compliance/audit/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `Compliance Audit ${new Date().toLocaleDateString('de-DE')}`,
auditor_name: 'Aktueller Benutzer',
regulation_codes: ['GDPR'],
}),
})
if (res.ok) {
// Reload data
window.location.reload()
} else {
setError('Fehler beim Erstellen der neuen Checkliste')
}
} catch {
setError('Verbindung zum Backend fehlgeschlagen')
}
}
const stepInfo = STEP_EXPLANATIONS['audit-checklist']
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="audit-checklist"
title={stepInfo.title}
@@ -595,7 +89,6 @@ export default function AuditChecklistPage() {
</div>
</StepHeader>
{/* Error Banner */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
@@ -603,7 +96,6 @@ export default function AuditChecklistPage() {
</div>
)}
{/* Requirements Alert */}
{state.requirements.length === 0 && !loading && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
@@ -620,7 +112,6 @@ export default function AuditChecklistPage() {
</div>
)}
{/* Checklist Info */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
@@ -649,7 +140,6 @@ export default function AuditChecklistPage() {
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-green-200 p-4">
<div className="text-sm text-green-600">Konform</div>
@@ -669,7 +159,6 @@ export default function AuditChecklistPage() {
</div>
</div>
{/* Filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'not-reviewed', 'non-compliant', 'partial', 'compliant'].map(f => (
@@ -690,10 +179,8 @@ export default function AuditChecklistPage() {
))}
</div>
{/* Loading State */}
{loading && <LoadingSkeleton />}
{/* Checklist Items */}
{!loading && (
<div className="space-y-4">
{filteredItems.map(item => (
@@ -720,61 +207,7 @@ export default function AuditChecklistPage() {
</div>
)}
{/* Session History */}
{pastSessions.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Vergangene Audit-Sessions</h3>
<div className="space-y-3">
{pastSessions
.filter(s => s.id !== activeSessionId)
.map(session => {
const statusBadge: Record<string, string> = {
draft: 'bg-slate-100 text-slate-700',
in_progress: 'bg-blue-100 text-blue-700',
completed: 'bg-green-100 text-green-700',
archived: 'bg-purple-100 text-purple-700',
}
const statusLabel: Record<string, string> = {
draft: 'Entwurf',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen',
archived: 'Archiviert',
}
return (
<div
key={session.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"
onClick={() => router.push(`/sdk/audit-report/${session.id}`)}
>
<div className="flex items-center gap-3">
<span className={`px-2 py-0.5 text-xs rounded-full ${statusBadge[session.status] || ''}`}>
{statusLabel[session.status] || session.status}
</span>
<span className="text-sm font-medium text-gray-900">{session.name}</span>
<span className="text-xs text-gray-500">
{new Date(session.created_at).toLocaleDateString('de-DE')}
</span>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-500">
{session.completed_items}/{session.total_items} Punkte
</span>
<span className={`font-medium ${
session.completion_percentage >= 80 ? 'text-green-600' :
session.completion_percentage >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>
{session.completion_percentage}%
</span>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
)
})}
</div>
</div>
)}
<SessionHistory pastSessions={pastSessions} activeSessionId={activeSessionId} />
</div>
)
}

View File

@@ -0,0 +1,128 @@
'use client'
import { StatCard } from './StatCard'
import { formatNumber, type ComplianceReport } from './types'
export function ComplianceTab({ complianceReport }: { complianceReport: ComplianceReport }) {
return (
<div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<StatCard label="Requests" value={formatNumber(complianceReport.total_requests)} />
<StatCard
label="PII-Vorfaelle"
value={formatNumber(complianceReport.pii_incidents)}
highlight={complianceReport.pii_incidents > 0}
/>
<StatCard
label="PII-Rate"
value={`${(complianceReport.pii_rate * 100).toFixed(1)}%`}
highlight={complianceReport.pii_rate > 0.05}
/>
<StatCard label="Redaction-Rate" value={`${(complianceReport.redaction_rate * 100).toFixed(1)}%`} />
</div>
{complianceReport.policy_violations > 0 && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
<div className="flex items-center gap-2 text-red-700 font-semibold">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{complianceReport.policy_violations} Policy-Verletzungen im Zeitraum
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* PII Categories */}
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">PII-Kategorien</h3>
{Object.entries(complianceReport.top_pii_categories || {}).length === 0 ? (
<p className="text-gray-400 text-sm">Keine PII erkannt</p>
) : (
<div className="space-y-2">
{Object.entries(complianceReport.top_pii_categories).sort((a, b) => b[1] - a[1]).map(([cat, count]) => (
<div key={cat} className="flex items-center justify-between py-1">
<span className="text-sm text-gray-700">{cat}</span>
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-mono">{count}</span>
</div>
))}
</div>
)}
</div>
{/* Namespace Breakdown */}
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Namespace-Analyse</h3>
{Object.entries(complianceReport.namespace_breakdown || {}).length === 0 ? (
<p className="text-gray-400 text-sm">Keine Namespace-Daten</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left py-2 text-gray-500 font-medium">Namespace</th>
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
<th className="text-right py-2 text-gray-500 font-medium">PII</th>
</tr>
</thead>
<tbody>
{Object.entries(complianceReport.namespace_breakdown).map(([ns, data]) => (
<tr key={ns} className="border-b border-gray-50">
<td className="py-2 font-mono text-xs">{ns}</td>
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
<td className="py-2 text-right">
{data.pii_incidents > 0 ? (
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* User Breakdown */}
{Object.entries(complianceReport.user_breakdown || {}).length > 0 && (
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Top-Nutzer</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left py-2 text-gray-500 font-medium">User-ID</th>
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
<th className="text-right py-2 text-gray-500 font-medium">PII-Vorfaelle</th>
</tr>
</thead>
<tbody>
{Object.entries(complianceReport.user_breakdown)
.sort((a, b) => b[1].requests - a[1].requests)
.slice(0, 10)
.map(([userId, data]) => (
<tr key={userId} className="border-b border-gray-50">
<td className="py-2 font-mono text-xs">{userId}</td>
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
<td className="py-2 text-right">
{data.pii_incidents > 0 ? (
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
'use client'
import { formatDate, formatNumber, formatDuration, type LLMLogEntry } from './types'
interface Props {
logEntries: LLMLogEntry[]
logFilter: { model: string; pii: string }
onFilterChange: (filter: { model: string; pii: string }) => void
}
export function LLMLogTab({ logEntries, logFilter, onFilterChange }: Props) {
return (
<div>
{/* Filters */}
<div className="flex gap-3 mb-4">
<input
type="text"
placeholder="Model filtern..."
value={logFilter.model}
onChange={e => onFilterChange({ ...logFilter, model: e.target.value })}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
/>
<select
value={logFilter.pii}
onChange={e => onFilterChange({ ...logFilter, pii: e.target.value })}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
>
<option value="">Alle PII-Status</option>
<option value="true">PII erkannt</option>
<option value="false">Kein PII</option>
</select>
</div>
{/* Table */}
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-600">Zeitpunkt</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">User</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Model</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Tokens</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">PII</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Dauer</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">Status</th>
</tr>
</thead>
<tbody>
{logEntries.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-400">
Keine Log-Eintraege im gewaehlten Zeitraum
</td>
</tr>
) : logEntries.map(entry => (
<tr key={entry.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 text-gray-500">{formatDate(entry.created_at)}</td>
<td className="px-4 py-3 font-mono text-xs">{entry.user_id?.slice(0, 8)}...</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
{entry.model}
</span>
</td>
<td className="px-4 py-3 text-right font-mono">{formatNumber(entry.total_tokens)}</td>
<td className="px-4 py-3 text-center">
{entry.pii_detected ? (
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-medium">
{entry.redacted ? 'Redacted' : 'Erkannt'}
</span>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</td>
<td className="px-4 py-3 text-right text-gray-500">{formatDuration(entry.duration_ms)}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
entry.status === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{entry.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 text-xs text-gray-400">{logEntries.length} Eintraege</div>
</div>
)
}

View File

@@ -0,0 +1,10 @@
'use client'
export function StatCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<div className={`rounded-xl border p-4 ${highlight ? 'border-red-200 bg-red-50' : 'border-gray-200 bg-white'}`}>
<div className="text-sm text-gray-500">{label}</div>
<div className={`text-2xl font-bold mt-1 ${highlight ? 'text-red-700' : 'text-gray-900'}`}>{value}</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import { StatCard } from './StatCard'
import { formatNumber, formatDuration, type UsageStats } from './types'
export function UsageTab({ usageStats }: { usageStats: UsageStats }) {
return (
<div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<StatCard label="Requests gesamt" value={formatNumber(usageStats.total_requests)} />
<StatCard label="Tokens gesamt" value={formatNumber(usageStats.total_tokens)} />
<StatCard label="Avg. Dauer" value={formatDuration(usageStats.avg_duration_ms)} />
<StatCard
label="PII-Rate"
value={`${(usageStats.pii_detection_rate * 100).toFixed(1)}%`}
highlight={usageStats.pii_detection_rate > 0.1}
/>
</div>
{/* Token Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Model-Nutzung</h3>
{Object.entries(usageStats.models_used || {}).length === 0 ? (
<p className="text-gray-400 text-sm">Keine Daten</p>
) : (
<div className="space-y-3">
{Object.entries(usageStats.models_used).sort((a, b) => b[1] - a[1]).map(([model, count]) => (
<div key={model} className="flex items-center justify-between">
<span className="text-sm text-gray-700">{model}</span>
<div className="flex items-center gap-3">
<div className="w-32 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 rounded-full"
style={{ width: `${(count / usageStats.total_requests) * 100}%` }}
/>
</div>
<span className="text-sm font-mono text-gray-500 w-16 text-right">{formatNumber(count)}</span>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Provider-Verteilung</h3>
{Object.entries(usageStats.providers_used || {}).length === 0 ? (
<p className="text-gray-400 text-sm">Keine Daten</p>
) : (
<div className="space-y-3">
{Object.entries(usageStats.providers_used).sort((a, b) => b[1] - a[1]).map(([provider, count]) => (
<div key={provider} className="flex items-center justify-between">
<span className="text-sm text-gray-700 capitalize">{provider}</span>
<span className="text-sm font-mono text-gray-500">{formatNumber(count)}</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Token Details */}
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Token-Aufschluesselung</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_prompt_tokens)}</div>
<div className="text-sm text-gray-500">Prompt Tokens</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_completion_tokens)}</div>
<div className="text-sm text-gray-500">Completion Tokens</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">{formatNumber(usageStats.total_tokens)}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
export interface LLMLogEntry {
id: string
user_id: string
namespace: string
model: string
provider: string
prompt_tokens: number
completion_tokens: number
total_tokens: number
pii_detected: boolean
pii_categories: string[]
redacted: boolean
duration_ms: number
status: string
created_at: string
}
export interface UsageStats {
total_requests: number
total_tokens: number
total_prompt_tokens: number
total_completion_tokens: number
models_used: Record<string, number>
providers_used: Record<string, number>
avg_duration_ms: number
pii_detection_rate: number
period_start: string
period_end: string
}
export interface ComplianceReport {
total_requests: number
pii_incidents: number
pii_rate: number
redaction_rate: number
policy_violations: number
top_pii_categories: Record<string, number>
namespace_breakdown: Record<string, { requests: number; pii_incidents: number }>
user_breakdown: Record<string, { requests: number; pii_incidents: number }>
period_start: string
period_end: string
}
export type TabId = 'llm-log' | 'usage' | 'compliance'
export const API_BASE = '/api/sdk/v1/audit-llm'
export function formatDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
export function formatNumber(n: number): string {
return n.toLocaleString('de-DE')
}
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
export function getDateRange(period: string): { from: string; to: string } {
const now = new Date()
const to = now.toISOString().slice(0, 10)
const from = new Date(now)
switch (period) {
case '7d': from.setDate(from.getDate() - 7); break
case '30d': from.setDate(from.getDate() - 30); break
case '90d': from.setDate(from.getDate() - 90); break
default: from.setDate(from.getDate() - 7)
}
return { from: from.toISOString().slice(0, 10), to }
}

View File

@@ -0,0 +1,94 @@
'use client'
import { useState, useCallback } from 'react'
import { API_BASE, getDateRange, type LLMLogEntry, type UsageStats, type ComplianceReport } from '../_components/types'
export function useAuditData(period: string, logFilter: { model: string; pii: string }) {
const [logEntries, setLogEntries] = useState<LLMLogEntry[]>([])
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
const [complianceReport, setComplianceReport] = useState<ComplianceReport | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadLLMLog = useCallback(async () => {
setLoading(true)
setError(null)
try {
const { from, to } = getDateRange(period)
const params = new URLSearchParams({ from, to, limit: '100' })
if (logFilter.model) params.set('model', logFilter.model)
if (logFilter.pii === 'true') params.set('pii_detected', 'true')
if (logFilter.pii === 'false') params.set('pii_detected', 'false')
const res = await fetch(`${API_BASE}/llm?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setLogEntries(Array.isArray(data) ? data : data.entries || data.logs || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [period, logFilter])
const loadUsage = useCallback(async () => {
setLoading(true)
setError(null)
try {
const { from, to } = getDateRange(period)
const res = await fetch(`${API_BASE}/usage?from=${from}&to=${to}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setUsageStats(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [period])
const loadCompliance = useCallback(async () => {
setLoading(true)
setError(null)
try {
const { from, to } = getDateRange(period)
const res = await fetch(`${API_BASE}/compliance-report?from=${from}&to=${to}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setComplianceReport(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [period])
const handleExport = useCallback(async (type: 'llm' | 'general' | 'compliance', format: 'json' | 'csv') => {
try {
const { from, to } = getDateRange(period)
const res = await fetch(`${API_BASE}/export/${type}?from=${from}&to=${to}&format=${format}`)
if (!res.ok) throw new Error(`Export fehlgeschlagen: ${res.status}`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-${type}-${from}-${to}.${format}`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
setError(e instanceof Error ? e.message : 'Export fehlgeschlagen')
}
}, [period])
return {
logEntries,
usageStats,
complianceReport,
loading,
error,
loadLLMLog,
loadUsage,
loadCompliance,
handleExport,
}
}

View File

@@ -1,167 +1,36 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import { useAuditData } from './_hooks/useAuditData'
import { LLMLogTab } from './_components/LLMLogTab'
import { UsageTab } from './_components/UsageTab'
import { ComplianceTab } from './_components/ComplianceTab'
import type { TabId } from './_components/types'
// =============================================================================
// TYPES
// =============================================================================
interface LLMLogEntry {
id: string
user_id: string
namespace: string
model: string
provider: string
prompt_tokens: number
completion_tokens: number
total_tokens: number
pii_detected: boolean
pii_categories: string[]
redacted: boolean
duration_ms: number
status: string
created_at: string
}
interface UsageStats {
total_requests: number
total_tokens: number
total_prompt_tokens: number
total_completion_tokens: number
models_used: Record<string, number>
providers_used: Record<string, number>
avg_duration_ms: number
pii_detection_rate: number
period_start: string
period_end: string
}
interface ComplianceReport {
total_requests: number
pii_incidents: number
pii_rate: number
redaction_rate: number
policy_violations: number
top_pii_categories: Record<string, number>
namespace_breakdown: Record<string, { requests: number; pii_incidents: number }>
user_breakdown: Record<string, { requests: number; pii_incidents: number }>
period_start: string
period_end: string
}
type TabId = 'llm-log' | 'usage' | 'compliance'
// =============================================================================
// HELPERS
// =============================================================================
const API_BASE = '/api/sdk/v1/audit-llm'
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
function formatNumber(n: number): string {
return n.toLocaleString('de-DE')
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
function getDateRange(period: string): { from: string; to: string } {
const now = new Date()
const to = now.toISOString().slice(0, 10)
const from = new Date(now)
switch (period) {
case '7d': from.setDate(from.getDate() - 7); break
case '30d': from.setDate(from.getDate() - 30); break
case '90d': from.setDate(from.getDate() - 90); break
default: from.setDate(from.getDate() - 7)
}
return { from: from.toISOString().slice(0, 10), to }
}
// =============================================================================
// MAIN PAGE
// =============================================================================
const tabs: { id: TabId; label: string }[] = [
{ id: 'llm-log', label: 'LLM-Log' },
{ id: 'usage', label: 'Nutzung' },
{ id: 'compliance', label: 'Compliance' },
]
export default function AuditLLMPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('llm-log')
const [period, setPeriod] = useState('7d')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// LLM Log state
const [logEntries, setLogEntries] = useState<LLMLogEntry[]>([])
const [logFilter, setLogFilter] = useState({ model: '', pii: '' })
// Usage state
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
// Compliance state
const [complianceReport, setComplianceReport] = useState<ComplianceReport | null>(null)
// ─── Load Data ───────────────────────────────────────────────────────
const loadLLMLog = useCallback(async () => {
setLoading(true)
setError(null)
try {
const { from, to } = getDateRange(period)
const params = new URLSearchParams({ from, to, limit: '100' })
if (logFilter.model) params.set('model', logFilter.model)
if (logFilter.pii === 'true') params.set('pii_detected', 'true')
if (logFilter.pii === 'false') params.set('pii_detected', 'false')
const res = await fetch(`${API_BASE}/llm?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setLogEntries(Array.isArray(data) ? data : data.entries || data.logs || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [period, logFilter])
const loadUsage = useCallback(async () => {
setLoading(true)
setError(null)
try {
const { from, to } = getDateRange(period)
const res = await fetch(`${API_BASE}/usage?from=${from}&to=${to}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setUsageStats(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [period])
const loadCompliance = useCallback(async () => {
setLoading(true)
setError(null)
try {
const { from, to } = getDateRange(period)
const res = await fetch(`${API_BASE}/compliance-report?from=${from}&to=${to}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setComplianceReport(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [period])
const {
logEntries,
usageStats,
complianceReport,
loading,
error,
loadLLMLog,
loadUsage,
loadCompliance,
handleExport,
} = useAuditData(period, logFilter)
useEffect(() => {
if (activeTab === 'llm-log') loadLLMLog()
@@ -169,42 +38,13 @@ export default function AuditLLMPage() {
else if (activeTab === 'compliance') loadCompliance()
}, [activeTab, loadLLMLog, loadUsage, loadCompliance])
// ─── Export ──────────────────────────────────────────────────────────
const handleExport = async (type: 'llm' | 'general' | 'compliance', format: 'json' | 'csv') => {
try {
const { from, to } = getDateRange(period)
const res = await fetch(`${API_BASE}/export/${type}?from=${from}&to=${to}&format=${format}`)
if (!res.ok) throw new Error(`Export fehlgeschlagen: ${res.status}`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `audit-${type}-${from}-${to}.${format}`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
setError(e instanceof Error ? e.message : 'Export fehlgeschlagen')
}
}
// ─── Tabs ────────────────────────────────────────────────────────────
const tabs: { id: TabId; label: string }[] = [
{ id: 'llm-log', label: 'LLM-Log' },
{ id: 'usage', label: 'Nutzung' },
{ id: 'compliance', label: 'Compliance' },
]
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">LLM Audit Dashboard</h1>
<p className="text-gray-500 mt-1">Monitoring und Compliance-Analyse der LLM-Operationen</p>
</div>
{/* Period + Tabs */}
<div className="flex items-center justify-between mb-6">
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
{tabs.map(tab => (
@@ -254,289 +94,22 @@ export default function AuditLLMPage() {
</div>
)}
{/* ── LLM-Log Tab ── */}
{!loading && activeTab === 'llm-log' && (
<div>
{/* Filters */}
<div className="flex gap-3 mb-4">
<input
type="text"
placeholder="Model filtern..."
value={logFilter.model}
onChange={e => setLogFilter(f => ({ ...f, model: e.target.value }))}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
/>
<select
value={logFilter.pii}
onChange={e => setLogFilter(f => ({ ...f, pii: e.target.value }))}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
>
<option value="">Alle PII-Status</option>
<option value="true">PII erkannt</option>
<option value="false">Kein PII</option>
</select>
</div>
{/* Table */}
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left px-4 py-3 font-medium text-gray-600">Zeitpunkt</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">User</th>
<th className="text-left px-4 py-3 font-medium text-gray-600">Model</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Tokens</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">PII</th>
<th className="text-right px-4 py-3 font-medium text-gray-600">Dauer</th>
<th className="text-center px-4 py-3 font-medium text-gray-600">Status</th>
</tr>
</thead>
<tbody>
{logEntries.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-400">
Keine Log-Eintraege im gewaehlten Zeitraum
</td>
</tr>
) : logEntries.map(entry => (
<tr key={entry.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 text-gray-500">{formatDate(entry.created_at)}</td>
<td className="px-4 py-3 font-mono text-xs">{entry.user_id?.slice(0, 8)}...</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
{entry.model}
</span>
</td>
<td className="px-4 py-3 text-right font-mono">{formatNumber(entry.total_tokens)}</td>
<td className="px-4 py-3 text-center">
{entry.pii_detected ? (
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-medium">
{entry.redacted ? 'Redacted' : 'Erkannt'}
</span>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</td>
<td className="px-4 py-3 text-right text-gray-500">{formatDuration(entry.duration_ms)}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
entry.status === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{entry.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 text-xs text-gray-400">{logEntries.length} Eintraege</div>
</div>
<LLMLogTab
logEntries={logEntries}
logFilter={logFilter}
onFilterChange={setLogFilter}
/>
)}
{/* ── Nutzung Tab ── */}
{!loading && activeTab === 'usage' && usageStats && (
<div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<StatCard label="Requests gesamt" value={formatNumber(usageStats.total_requests)} />
<StatCard label="Tokens gesamt" value={formatNumber(usageStats.total_tokens)} />
<StatCard label="Avg. Dauer" value={formatDuration(usageStats.avg_duration_ms)} />
<StatCard
label="PII-Rate"
value={`${(usageStats.pii_detection_rate * 100).toFixed(1)}%`}
highlight={usageStats.pii_detection_rate > 0.1}
/>
</div>
{/* Token Breakdown */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Model-Nutzung</h3>
{Object.entries(usageStats.models_used || {}).length === 0 ? (
<p className="text-gray-400 text-sm">Keine Daten</p>
) : (
<div className="space-y-3">
{Object.entries(usageStats.models_used).sort((a, b) => b[1] - a[1]).map(([model, count]) => (
<div key={model} className="flex items-center justify-between">
<span className="text-sm text-gray-700">{model}</span>
<div className="flex items-center gap-3">
<div className="w-32 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 rounded-full"
style={{ width: `${(count / usageStats.total_requests) * 100}%` }}
/>
</div>
<span className="text-sm font-mono text-gray-500 w-16 text-right">{formatNumber(count)}</span>
</div>
</div>
))}
</div>
)}
</div>
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Provider-Verteilung</h3>
{Object.entries(usageStats.providers_used || {}).length === 0 ? (
<p className="text-gray-400 text-sm">Keine Daten</p>
) : (
<div className="space-y-3">
{Object.entries(usageStats.providers_used).sort((a, b) => b[1] - a[1]).map(([provider, count]) => (
<div key={provider} className="flex items-center justify-between">
<span className="text-sm text-gray-700 capitalize">{provider}</span>
<span className="text-sm font-mono text-gray-500">{formatNumber(count)}</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Token Details */}
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Token-Aufschluesselung</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_prompt_tokens)}</div>
<div className="text-sm text-gray-500">Prompt Tokens</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_completion_tokens)}</div>
<div className="text-sm text-gray-500">Completion Tokens</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">{formatNumber(usageStats.total_tokens)}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
</div>
</div>
</div>
<UsageTab usageStats={usageStats} />
)}
{/* ── Compliance Tab ── */}
{!loading && activeTab === 'compliance' && complianceReport && (
<div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<StatCard label="Requests" value={formatNumber(complianceReport.total_requests)} />
<StatCard
label="PII-Vorfaelle"
value={formatNumber(complianceReport.pii_incidents)}
highlight={complianceReport.pii_incidents > 0}
/>
<StatCard
label="PII-Rate"
value={`${(complianceReport.pii_rate * 100).toFixed(1)}%`}
highlight={complianceReport.pii_rate > 0.05}
/>
<StatCard label="Redaction-Rate" value={`${(complianceReport.redaction_rate * 100).toFixed(1)}%`} />
</div>
{complianceReport.policy_violations > 0 && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
<div className="flex items-center gap-2 text-red-700 font-semibold">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{complianceReport.policy_violations} Policy-Verletzungen im Zeitraum
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* PII Categories */}
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">PII-Kategorien</h3>
{Object.entries(complianceReport.top_pii_categories || {}).length === 0 ? (
<p className="text-gray-400 text-sm">Keine PII erkannt</p>
) : (
<div className="space-y-2">
{Object.entries(complianceReport.top_pii_categories).sort((a, b) => b[1] - a[1]).map(([cat, count]) => (
<div key={cat} className="flex items-center justify-between py-1">
<span className="text-sm text-gray-700">{cat}</span>
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-mono">{count}</span>
</div>
))}
</div>
)}
</div>
{/* Namespace Breakdown */}
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Namespace-Analyse</h3>
{Object.entries(complianceReport.namespace_breakdown || {}).length === 0 ? (
<p className="text-gray-400 text-sm">Keine Namespace-Daten</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left py-2 text-gray-500 font-medium">Namespace</th>
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
<th className="text-right py-2 text-gray-500 font-medium">PII</th>
</tr>
</thead>
<tbody>
{Object.entries(complianceReport.namespace_breakdown).map(([ns, data]) => (
<tr key={ns} className="border-b border-gray-50">
<td className="py-2 font-mono text-xs">{ns}</td>
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
<td className="py-2 text-right">
{data.pii_incidents > 0 ? (
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* User Breakdown */}
{Object.entries(complianceReport.user_breakdown || {}).length > 0 && (
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
<h3 className="font-semibold text-gray-900 mb-4">Top-Nutzer</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="text-left py-2 text-gray-500 font-medium">User-ID</th>
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
<th className="text-right py-2 text-gray-500 font-medium">PII-Vorfaelle</th>
</tr>
</thead>
<tbody>
{Object.entries(complianceReport.user_breakdown)
.sort((a, b) => b[1].requests - a[1].requests)
.slice(0, 10)
.map(([userId, data]) => (
<tr key={userId} className="border-b border-gray-50">
<td className="py-2 font-mono text-xs">{userId}</td>
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
<td className="py-2 text-right">
{data.pii_incidents > 0 ? (
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
<ComplianceTab complianceReport={complianceReport} />
)}
{/* Empty state for usage/compliance when no data */}
{!loading && activeTab === 'usage' && !usageStats && !error && (
<div className="text-center py-12 text-gray-400">Keine Nutzungsdaten verfuegbar</div>
)}
@@ -546,16 +119,3 @@ export default function AuditLLMPage() {
</div>
)
}
// =============================================================================
// STAT CARD
// =============================================================================
function StatCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<div className={`rounded-xl border p-4 ${highlight ? 'border-red-200 bg-red-50' : 'border-gray-200 bg-white'}`}>
<div className="text-sm text-gray-500">{label}</div>
<div className={`text-2xl font-bold mt-1 ${highlight ? 'text-red-700' : 'text-gray-900'}`}>{value}</div>
</div>
)
}

View File

@@ -0,0 +1,94 @@
'use client'
import { CompanyProfile, BUSINESS_MODEL_LABELS, COMPANY_SIZE_LABELS, TARGET_MARKET_LABELS } from '@/lib/sdk/types'
import { LEGAL_FORM_LABELS } from './constants'
export function ProfileSummary({
formData,
onEdit,
onContinue,
}: {
formData: Partial<CompanyProfile>
onEdit: () => void
onContinue: () => void
}) {
const summaryItems = [
{ label: 'Firmenname', value: formData.companyName },
{ label: 'Rechtsform', value: formData.legalForm ? LEGAL_FORM_LABELS[formData.legalForm] : undefined },
{ label: 'Branche', value: formData.industry?.join(', ') },
{ label: 'Geschaeftsmodell', value: formData.businessModel ? BUSINESS_MODEL_LABELS[formData.businessModel]?.short : undefined },
{ label: 'Unternehmensgroesse', value: formData.companySize ? COMPANY_SIZE_LABELS[formData.companySize] : undefined },
{ label: 'Mitarbeiter', value: formData.employeeCount },
{ label: 'Hauptsitz', value: [formData.headquartersZip, formData.headquartersCity, formData.headquartersCountry === 'DE' ? 'Deutschland' : formData.headquartersCountry].filter(Boolean).join(', ') },
{ label: 'Zielmaerkte', value: formData.targetMarkets?.map(m => TARGET_MARKET_LABELS[m] || m).join(', ') },
{ label: 'Verantwortlicher', value: formData.isDataController ? 'Ja' : 'Nein' },
{ label: 'Auftragsverarbeiter', value: formData.isDataProcessor ? 'Ja' : 'Nein' },
{ label: 'DSB', value: formData.dpoName || 'Nicht angegeben' },
].filter(item => item.value && item.value.length > 0)
const missingFields: string[] = []
if (!formData.companyName) missingFields.push('Firmenname')
if (!formData.legalForm) missingFields.push('Rechtsform')
if (!formData.industry || formData.industry.length === 0) missingFields.push('Branche')
if (!formData.businessModel) missingFields.push('Geschaeftsmodell')
if (!formData.companySize) missingFields.push('Unternehmensgroesse')
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Unternehmensprofil</h1>
</div>
{/* Success Banner */}
<div className={`rounded-xl border-2 p-6 mb-6 ${formData.isComplete ? 'bg-green-50 border-green-300' : 'bg-yellow-50 border-yellow-300'}`}>
<div className="flex items-start gap-4">
<div className={`flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center ${formData.isComplete ? 'bg-green-200' : 'bg-yellow-200'}`}>
<span className="text-2xl">{formData.isComplete ? '\u2713' : '!'}</span>
</div>
<div>
<h2 className={`text-xl font-bold ${formData.isComplete ? 'text-green-800' : 'text-yellow-800'}`}>
{formData.isComplete ? 'Profil erfolgreich abgeschlossen' : 'Profil unvollstaendig'}
</h2>
<p className={`mt-1 ${formData.isComplete ? 'text-green-700' : 'text-yellow-700'}`}>
{formData.isComplete
? 'Alle Angaben wurden gespeichert. Sie koennen jetzt mit der Scope-Analyse fortfahren.'
: `Es fehlen noch Angaben: ${missingFields.join(', ')}.`}
</p>
</div>
</div>
</div>
{/* Profile Summary */}
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Zusammenfassung</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{summaryItems.map(item => (
<div key={item.label} className="flex flex-col">
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">{item.label}</span>
<span className="text-sm text-gray-900 mt-0.5">{item.value}</span>
</div>
))}
</div>
</div>
{/* Actions */}
<div className="flex justify-between items-center">
<button onClick={onEdit} className="px-6 py-3 text-gray-600 hover:text-gray-900 border border-gray-300 rounded-lg hover:bg-gray-50">
Profil bearbeiten
</button>
{formData.isComplete ? (
<button onClick={onContinue} className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
Weiter zu Scope
</button>
) : (
<button onClick={onEdit} className="px-8 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-medium">
Fehlende Angaben ergaenzen
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,207 @@
'use client'
import { useState } from 'react'
import { CompanyProfile } from '@/lib/sdk/types'
import { AISystem, AISystemTemplate } from './types'
import { AI_SYSTEM_TEMPLATES } from './ai-system-data'
export function StepAISystems({
data,
onChange,
}: {
data: Partial<CompanyProfile> & { aiSystems?: AISystem[] }
onChange: (updates: Record<string, unknown>) => void
}) {
const aiSystems: AISystem[] = (data as any).aiSystems || []
const [expandedSystem, setExpandedSystem] = useState<string | null>(null)
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
const activeIds = new Set(aiSystems.map(a => a.id))
const toggleTemplateSystem = (template: AISystemTemplate) => {
if (activeIds.has(template.id)) {
onChange({ aiSystems: aiSystems.filter(a => a.id !== template.id) })
if (expandedSystem === template.id) setExpandedSystem(null)
} else {
const newSystem: AISystem = {
id: template.id, name: template.name, vendor: template.vendor,
purpose: template.typicalPurposes.join(', '), purposes: [],
processes_personal_data: template.processes_personal_data_likely, isCustom: false,
}
onChange({ aiSystems: [...aiSystems, newSystem] })
setExpandedSystem(template.id)
}
}
const updateAISystem = (id: string, updates: Partial<AISystem>) => {
onChange({ aiSystems: aiSystems.map(a => a.id === id ? { ...a, ...updates } : a) })
}
const togglePurpose = (systemId: string, purpose: string) => {
const system = aiSystems.find(a => a.id === systemId)
if (!system) return
const purposes = system.purposes || []
const updated = purposes.includes(purpose) ? purposes.filter(p => p !== purpose) : [...purposes, purpose]
updateAISystem(systemId, { purposes: updated, purpose: updated.join(', ') })
}
const addCustomSystem = () => {
const id = `custom_ai_${Date.now()}`
onChange({ aiSystems: [...aiSystems, { id, name: '', vendor: '', purpose: '', processes_personal_data: false, isCustom: true }] })
setExpandedSystem(id)
}
const removeSystem = (id: string) => {
onChange({ aiSystems: aiSystems.filter(a => a.id !== id) })
if (expandedSystem === id) setExpandedSystem(null)
}
const toggleCategoryCollapse = (category: string) => {
setCollapsedCategories(prev => { const next = new Set(prev); if (next.has(category)) next.delete(category); else next.add(category); return next })
}
const categoryActiveCount = (systems: AISystemTemplate[]) => systems.filter(s => activeIds.has(s.id)).length
return (
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-gray-700 mb-1">KI-Systeme im Einsatz</h3>
<p className="text-xs text-gray-500 mb-4">
Waehlen Sie die KI-Systeme aus, die in Ihrem Unternehmen eingesetzt werden. Dies dient der Erfassung fuer den EU AI Act und die DSGVO-Dokumentation.
</p>
</div>
<div className="space-y-4">
{AI_SYSTEM_TEMPLATES.map(group => {
const isCollapsed = collapsedCategories.has(group.category)
const activeCount = categoryActiveCount(group.systems)
return (
<div key={group.category} className="border border-gray-200 rounded-lg overflow-hidden">
<button type="button" onClick={() => toggleCategoryCollapse(group.category)} className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left">
<span className="text-base">{group.icon}</span>
<span className="text-sm font-medium text-gray-900 flex-1">{group.category}</span>
{activeCount > 0 && <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{activeCount} aktiv</span>}
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{!isCollapsed && (
<div className="p-3 space-y-2">
{group.systems.map(template => {
const isActive = activeIds.has(template.id)
const system = aiSystems.find(a => a.id === template.id)
const isExpanded = expandedSystem === template.id
return (
<div key={template.id}>
<div
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${isActive ? 'border-purple-500 bg-purple-50' : 'border-gray-100 hover:border-purple-300'}`}
onClick={() => { if (!isActive) { toggleTemplateSystem(template) } else { setExpandedSystem(isExpanded ? null : template.id) } }}
>
<input type="checkbox" checked={isActive} onChange={e => { e.stopPropagation(); toggleTemplateSystem(template) }} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900">{template.name}</div>
<p className="text-xs text-gray-500">{template.vendor}</p>
</div>
{isActive && (
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</div>
{isActive && isExpanded && system && (
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">Einsatzzweck</label>
<div className="flex flex-wrap gap-2">
{template.typicalPurposes.map(purpose => (
<button key={purpose} type="button" onClick={() => togglePurpose(template.id, purpose)}
className={`px-3 py-1.5 text-xs rounded-full border transition-all ${(system.purposes || []).includes(purpose) ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-purple-200'}`}>
{purpose}
</button>
))}
</div>
<input type="text" value={system.notes || ''} onChange={e => updateAISystem(template.id, { notes: e.target.value })} placeholder="Weitere Einsatzzwecke / Anmerkungen..." className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
{template.dataWarning && (
<div className={`flex items-start gap-2 px-3 py-2 rounded-lg ${template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') || template.dataWarning.includes('NICHT') ? 'bg-blue-50 border border-blue-200' : 'bg-amber-50 border border-amber-200'}`}>
<span className="text-sm mt-0.5">{template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') ? '\u2139\uFE0F' : '\u26A0\uFE0F'}</span>
<span className="text-xs text-gray-800">{template.dataWarning}</span>
</div>
)}
<label className="flex items-center gap-2 px-1 cursor-pointer">
<input type="checkbox" checked={system.processes_personal_data} onChange={e => updateAISystem(template.id, { processes_personal_data: e.target.checked })} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
</label>
<button type="button" onClick={() => removeSystem(template.id)} className="text-xs text-red-500 hover:text-red-700">KI-System entfernen</button>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
{aiSystems.filter(a => a.isCustom).map(system => (
<div key={system.id} className="mt-2">
<div className="flex items-center gap-3 p-3 rounded-lg border-2 border-purple-500 bg-purple-50 cursor-pointer" onClick={() => setExpandedSystem(expandedSystem === system.id ? null : system.id)}>
<span className="w-4 h-4 flex items-center justify-center text-purple-600 flex-shrink-0 text-sm">+</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900">{system.name || 'Neues KI-System'}</span>
{system.vendor && <span className="text-xs text-gray-500 ml-2">({system.vendor})</span>}
</div>
<svg className={`w-4 h-4 text-gray-400 transition-transform ${expandedSystem === system.id ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{expandedSystem === system.id && (
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-3">
<div className="grid grid-cols-2 gap-3">
<input type="text" value={system.name} onChange={e => updateAISystem(system.id, { name: e.target.value })} placeholder="Name (z.B. ChatGPT, Copilot)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<input type="text" value={system.vendor} onChange={e => updateAISystem(system.id, { vendor: e.target.value })} placeholder="Anbieter (z.B. OpenAI, Microsoft)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<input type="text" value={system.purpose} onChange={e => updateAISystem(system.id, { purpose: e.target.value })} placeholder="Einsatzzweck (z.B. Kundensupport, Code-Assistenz)" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<label className="flex items-center gap-2 px-1 cursor-pointer">
<input type="checkbox" checked={system.processes_personal_data} onChange={e => updateAISystem(system.id, { processes_personal_data: e.target.checked })} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
</label>
<button type="button" onClick={() => removeSystem(system.id)} className="text-xs text-red-500 hover:text-red-700">KI-System entfernen</button>
</div>
)}
</div>
))}
<button type="button" onClick={addCustomSystem} className="w-full mt-3 px-3 py-2 text-sm text-purple-700 bg-purple-50 border-2 border-dashed border-purple-200 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-colors">
+ Eigenes KI-System hinzufuegen
</button>
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-lg">{'\u2139\uFE0F'}</span>
<div>
<h4 className="text-sm font-medium text-blue-900 mb-1">AI Act Risikoeinstufung</h4>
<p className="text-xs text-blue-800 mb-3">
Die detaillierte Risikoeinstufung Ihrer KI-Systeme nach EU AI Act (verboten / hochriskant / begrenzt / minimal) erfolgt automatisch im AI-Act-Modul.
</p>
<a href="/sdk/ai-act" className="inline-flex items-center gap-1 text-sm font-medium text-blue-700 hover:text-blue-900">
Zum AI-Act-Modul
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,107 @@
'use client'
import { CompanyProfile, LegalForm } from '@/lib/sdk/types'
import { INDUSTRIES, LEGAL_FORM_LABELS } from './constants'
export function StepBasicInfo({
data,
onChange,
}: {
data: Partial<CompanyProfile>
onChange: (updates: Partial<CompanyProfile>) => void
}) {
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Firmenname <span className="text-red-500">*</span>
</label>
<input
type="text"
value={data.companyName || ''}
onChange={e => onChange({ companyName: e.target.value })}
placeholder="Ihre Firma (ohne Rechtsform)"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rechtsform <span className="text-red-500">*</span>
</label>
<select
value={data.legalForm || ''}
onChange={e => onChange({ legalForm: e.target.value as LegalForm })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Bitte w&auml;hlen...</option>
{Object.entries(LEGAL_FORM_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Branche(n)</label>
<p className="text-sm text-gray-500 mb-3">Mehrfachauswahl moeglich</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{INDUSTRIES.map(ind => {
const selected = (data.industry || []).includes(ind)
return (
<button
key={ind}
type="button"
onClick={() => {
const current = data.industry || []
const updated = selected
? current.filter(i => i !== ind)
: [...current, ind]
onChange({ industry: updated })
}}
className={`p-3 rounded-lg border-2 text-sm text-left transition-all ${
selected
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-200 hover:border-purple-300 text-gray-700'
}`}
>
{ind}
</button>
)
})}
</div>
{(data.industry || []).includes('Sonstige') && (
<div className="mt-3">
<input
type="text"
value={data.industryOther || ''}
onChange={e => onChange({ industryOther: e.target.value })}
placeholder="Ihre Branche eingeben..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Gr&uuml;ndungsjahr</label>
<input
type="number"
value={data.foundedYear || ''}
onChange={e => {
const val = parseInt(e.target.value)
onChange({ foundedYear: isNaN(val) ? null : val })
}}
onFocus={e => {
if (!data.foundedYear) onChange({ foundedYear: 2000 })
}}
placeholder="2020"
min="1900"
max={new Date().getFullYear()}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
'use client'
import {
CompanyProfile,
BusinessModel,
OfferingType,
BUSINESS_MODEL_LABELS,
OFFERING_TYPE_LABELS,
} from '@/lib/sdk/types'
import { OFFERING_URL_CONFIG } from './constants'
export function StepBusinessModel({
data,
onChange,
}: {
data: Partial<CompanyProfile>
onChange: (updates: Partial<CompanyProfile>) => void
}) {
const toggleOffering = (offering: OfferingType) => {
const current = data.offerings || []
if (current.includes(offering)) {
const urls = { ...(data.offeringUrls || {}) }
delete urls[offering]
onChange({ offerings: current.filter(o => o !== offering), offeringUrls: urls })
} else {
onChange({ offerings: [...current, offering] })
}
}
const updateOfferingUrl = (offering: string, url: string) => {
onChange({ offeringUrls: { ...(data.offeringUrls || {}), [offering]: url } })
}
const selectedWithUrls = (data.offerings || []).filter(o => o in OFFERING_URL_CONFIG)
return (
<div className="space-y-8">
<div>
<label className="block text-sm font-medium text-gray-700 mb-4">
Gesch&auml;ftsmodell <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-4 gap-4">
{Object.entries(BUSINESS_MODEL_LABELS).map(([value, { short }]) => (
<button
key={value}
type="button"
onClick={() => onChange({ businessModel: value as BusinessModel })}
className={`p-4 rounded-xl border-2 text-center transition-all ${
data.businessModel === value
? 'border-purple-500 bg-purple-50 text-purple-700'
: 'border-gray-200 hover:border-purple-300'
}`}
>
<div className="font-semibold">{short}</div>
</button>
))}
</div>
{data.businessModel && (
<p className="text-sm text-gray-500 mt-2">
{BUSINESS_MODEL_LABELS[data.businessModel].description}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-4">
Was bieten Sie an? <span className="text-gray-400">(Mehrfachauswahl m&ouml;glich)</span>
</label>
<div className="grid grid-cols-2 gap-3">
{Object.entries(OFFERING_TYPE_LABELS).map(([value, { label, description }]) => (
<button
key={value}
type="button"
onClick={() => toggleOffering(value as OfferingType)}
className={`p-4 rounded-xl border-2 text-left transition-all ${
(data.offerings || []).includes(value as OfferingType)
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300'
}`}
>
<div className="font-medium text-gray-900">{label}</div>
<div className="text-sm text-gray-500">{description}</div>
</button>
))}
</div>
{(data.offerings || []).includes('webshop') && (data.offerings || []).includes('software_saas') && (
<div className="mt-3 flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-amber-800">
<strong>Hinweis:</strong> Wenn Sie reine Software verkaufen, genuegt <em>SaaS/Cloud</em> <em>Online-Shop</em> ist nur fuer physische Produkte oder Hardware mit Abo-Modell gedacht.
</p>
</div>
)}
</div>
{selectedWithUrls.length > 0 && (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700">
Zugeh&ouml;rige URLs
</label>
{selectedWithUrls.map(offering => {
const config = OFFERING_URL_CONFIG[offering]!
return (
<div key={offering}>
<label className="block text-sm text-gray-600 mb-1">{config.label}</label>
<input
type="url"
value={(data.offeringUrls || {})[offering] || ''}
onChange={e => updateOfferingUrl(offering, e.target.value)}
placeholder={config.placeholder}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<p className="text-xs text-gray-400 mt-1">{config.hint}</p>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import { CompanyProfile, CompanySize, COMPANY_SIZE_LABELS } from '@/lib/sdk/types'
export function StepCompanySize({
data,
onChange,
}: {
data: Partial<CompanyProfile>
onChange: (updates: Partial<CompanyProfile>) => void
}) {
return (
<div className="space-y-8">
<div>
<label className="block text-sm font-medium text-gray-700 mb-4">
Unternehmensgr&ouml;&szlig;e <span className="text-red-500">*</span>
</label>
<div className="space-y-3">
{Object.entries(COMPANY_SIZE_LABELS).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => onChange({ companySize: value as CompanySize })}
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
data.companySize === value
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300'
}`}
>
<div className="font-medium text-gray-900">{label}</div>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Jahresumsatz</label>
<div className="grid grid-cols-2 gap-3">
{[
{ value: '< 2 Mio', label: '< 2 Mio. Euro' },
{ value: '2-10 Mio', label: '2-10 Mio. Euro' },
{ value: '10-50 Mio', label: '10-50 Mio. Euro' },
{ value: '> 50 Mio', label: '> 50 Mio. Euro' },
].map(opt => (
<button
key={opt.value}
type="button"
onClick={() => onChange({ annualRevenue: opt.value })}
className={`p-4 rounded-xl border-2 text-left transition-all ${
data.annualRevenue === opt.value
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-200 hover:border-purple-300 text-gray-700'
}`}
>
{opt.label}
</button>
))}
</div>
{(data.companySize === 'medium' || data.companySize === 'large' || data.companySize === 'enterprise') && (
<p className="text-xs text-amber-600 mt-2">
Geben Sie den konsolidierten Konzernumsatz an, wenn der Compliance-Check f&uuml;r Mutter- und Tochtergesellschaften gelten soll.
F&uuml;r eine einzelne Einheit eines Konzerns geben Sie nur deren Umsatz an.
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { CompanyProfile } from '@/lib/sdk/types'
export function StepDataProtection({
data,
onChange,
}: {
data: Partial<CompanyProfile>
onChange: (updates: Partial<CompanyProfile>) => void
}) {
return (
<div className="space-y-8">
<div>
<label className="block text-sm font-medium text-gray-700 mb-4">
Datenschutz-Rolle nach DSGVO
</label>
<div className="space-y-3">
<label className="flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-purple-300 cursor-pointer">
<input
type="checkbox"
checked={data.isDataController ?? true}
onChange={e => onChange({ isDataController: e.target.checked })}
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">Verantwortlicher (Art. 4 Nr. 7 DSGVO)</div>
<div className="text-sm text-gray-500">
Wir entscheiden selbst &uuml;ber Zwecke und Mittel der Datenverarbeitung
</div>
</div>
</label>
<label className="flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-purple-300 cursor-pointer">
<input
type="checkbox"
checked={data.isDataProcessor ?? false}
onChange={e => onChange({ isDataProcessor: e.target.checked })}
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">Auftragsverarbeiter (Art. 4 Nr. 8 DSGVO)</div>
<div className="text-sm text-gray-500">
Wir verarbeiten personenbezogene Daten im Auftrag anderer Unternehmen
</div>
</div>
</label>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Datenschutzbeauftragter (Name)
</label>
<input
type="text"
value={data.dpoName || ''}
onChange={e => onChange({ dpoName: e.target.value || null })}
placeholder="Optional"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">DSB E-Mail</label>
<input
type="email"
value={data.dpoEmail || ''}
onChange={e => onChange({ dpoEmail: e.target.value || null })}
placeholder="dsb@firma.de"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
'use client'
import { CompanyProfile } from '@/lib/sdk/types'
import { CertificationEntry } from './types'
import { CERTIFICATIONS } from './constants'
export function StepLegalFramework({
data,
onChange,
}: {
data: Partial<CompanyProfile>
onChange: (updates: Record<string, unknown>) => void
}) {
const contacts = (data as any).technicalContacts || []
const existingCerts: CertificationEntry[] = (data as any).existingCertifications || []
const targetCerts: string[] = (data as any).targetCertifications || []
const targetCertOther: string = (data as any).targetCertificationOther || ''
const toggleExistingCert = (certId: string) => {
const exists = existingCerts.find((c: CertificationEntry) => c.certId === certId)
if (exists) {
onChange({ existingCertifications: existingCerts.filter((c: CertificationEntry) => c.certId !== certId) })
} else {
onChange({ existingCertifications: [...existingCerts, { certId }] })
}
}
const updateExistingCert = (certId: string, updates: Partial<CertificationEntry>) => {
onChange({ existingCertifications: existingCerts.map((c: CertificationEntry) => c.certId === certId ? { ...c, ...updates } : c) })
}
const toggleTargetCert = (certId: string) => {
if (targetCerts.includes(certId)) {
onChange({ targetCertifications: targetCerts.filter((c: string) => c !== certId) })
} else {
onChange({ targetCertifications: [...targetCerts, certId] })
}
}
const addContact = () => { onChange({ technicalContacts: [...contacts, { name: '', role: '', email: '' }] }) }
const removeContact = (i: number) => { onChange({ technicalContacts: contacts.filter((_: { name: string; role: string; email: string }, idx: number) => idx !== i) }) }
const updateContact = (i: number, updates: Partial<{ name: string; role: string; email: string }>) => {
const updated = [...contacts]
updated[i] = { ...updated[i], ...updates }
onChange({ technicalContacts: updated })
}
return (
<div className="space-y-8">
{/* Bestehende Zertifizierungen */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-1">Bestehende Zertifizierungen</h3>
<p className="text-sm text-gray-500 mb-3">Ueber welche Zertifizierungen verfuegt Ihr Unternehmen aktuell? Mehrfachauswahl moeglich.</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{CERTIFICATIONS.map(cert => {
const selected = existingCerts.some((c: CertificationEntry) => c.certId === cert.id)
return (
<button key={cert.id} type="button" onClick={() => toggleExistingCert(cert.id)}
className={`p-3 rounded-lg border-2 text-left transition-all ${selected ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 hover:border-purple-300 text-gray-700'}`}>
<div className="font-medium text-sm">{cert.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{cert.desc}</div>
</button>
)
})}
</div>
{existingCerts.length > 0 && (
<div className="mt-4 space-y-3">
{existingCerts.map((entry: CertificationEntry) => {
const cert = CERTIFICATIONS.find(c => c.id === entry.certId)
const label = cert?.label || entry.certId
return (
<div key={entry.certId} className="p-4 bg-purple-50 border border-purple-200 rounded-lg">
<div className="font-medium text-sm text-purple-800 mb-2">
{entry.certId === 'other' ? 'Sonstige Zertifizierung' : label}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{entry.certId === 'other' && (
<input type="text" value={entry.customName || ''} onChange={e => updateExistingCert(entry.certId, { customName: e.target.value })} placeholder="Name der Zertifizierung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
)}
<input type="text" value={entry.certifier || ''} onChange={e => updateExistingCert(entry.certId, { certifier: e.target.value })} placeholder="Zertifizierer (z.B. T\u00DCV, DEKRA)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<input type="date" value={entry.lastDate || ''} onChange={e => updateExistingCert(entry.certId, { lastDate: e.target.value })} title="Datum der letzten Zertifizierung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
</div>
)
})}
</div>
)}
</div>
{/* Angestrebte Zertifizierungen */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-sm font-medium text-gray-700 mb-1">Streben Sie eine Zertifizierung an?</h3>
<p className="text-sm text-gray-500 mb-3">Welche Zertifizierungen planen Sie? Mehrfachauswahl moeglich.</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{CERTIFICATIONS.map(cert => {
const selected = targetCerts.includes(cert.id)
const alreadyHas = existingCerts.some((c: CertificationEntry) => c.certId === cert.id)
return (
<button key={cert.id} type="button" onClick={() => !alreadyHas && toggleTargetCert(cert.id)} disabled={alreadyHas}
className={`p-3 rounded-lg border-2 text-left transition-all ${alreadyHas ? 'border-gray-100 bg-gray-50 text-gray-400 cursor-not-allowed' : selected ? 'border-green-500 bg-green-50 text-green-700' : 'border-gray-200 hover:border-green-300 text-gray-700'}`}>
<div className="font-medium text-sm">{cert.label}</div>
{alreadyHas && <div className="text-xs mt-0.5">Bereits vorhanden</div>}
{!alreadyHas && <div className="text-xs text-gray-500 mt-0.5">{cert.desc}</div>}
</button>
)
})}
</div>
{targetCerts.includes('other') && (
<div className="mt-3">
<input type="text" value={targetCertOther} onChange={e => onChange({ targetCertificationOther: e.target.value })} placeholder="Name der angestrebten Zertifizierung" className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
)}
</div>
{/* Technical Contacts */}
<div className="border-t border-gray-200 pt-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-medium text-gray-700">Technische Ansprechpartner</h3>
<p className="text-xs text-gray-500">CISO, IT-Manager, DSB etc.</p>
</div>
<button type="button" onClick={addContact} className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200">
+ Kontakt
</button>
</div>
{contacts.length === 0 && (
<div className="text-center py-4 text-gray-400 border-2 border-dashed rounded-lg text-sm">Noch keine Kontakte</div>
)}
<div className="space-y-3">
{contacts.map((c: { name: string; role: string; email: string }, i: number) => (
<div key={i} className="flex gap-3 items-center">
<input type="text" value={c.name} onChange={e => updateContact(i, { name: e.target.value })} placeholder="Name" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<input type="text" value={c.role} onChange={e => updateContact(i, { role: e.target.value })} placeholder="Rolle (z.B. CISO)" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<input type="email" value={c.email} onChange={e => updateContact(i, { email: e.target.value })} placeholder="E-Mail" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<button type="button" onClick={() => removeContact(i)} className="text-red-400 hover:text-red-600 text-sm">X</button>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,177 @@
'use client'
import { CompanyProfile, TargetMarket, TARGET_MARKET_LABELS } from '@/lib/sdk/types'
const STATES_BY_COUNTRY: Record<string, { label: string; options: string[] }> = {
DE: {
label: 'Bundesland',
options: [
'Baden-W\u00FCrttemberg', 'Bayern', 'Berlin', 'Brandenburg', 'Bremen',
'Hamburg', 'Hessen', 'Mecklenburg-Vorpommern', 'Niedersachsen',
'Nordrhein-Westfalen', 'Rheinland-Pfalz', 'Saarland', 'Sachsen',
'Sachsen-Anhalt', 'Schleswig-Holstein', 'Th\u00FCringen',
],
},
AT: {
label: 'Bundesland',
options: [
'Burgenland', 'K\u00E4rnten', 'Nieder\u00F6sterreich', 'Ober\u00F6sterreich',
'Salzburg', 'Steiermark', 'Tirol', 'Vorarlberg', 'Wien',
],
},
CH: {
label: 'Kanton',
options: [
'Aargau', 'Appenzell Ausserrhoden', 'Appenzell Innerrhoden',
'Basel-Landschaft', 'Basel-Stadt', 'Bern', 'Freiburg', 'Genf',
'Glarus', 'Graub\u00FCnden', 'Jura', 'Luzern', 'Neuenburg', 'Nidwalden',
'Obwalden', 'Schaffhausen', 'Schwyz', 'Solothurn', 'St. Gallen',
'Tessin', 'Thurgau', 'Uri', 'Waadt', 'Wallis', 'Zug', 'Z\u00FCrich',
],
},
}
export function StepLocations({
data,
onChange,
}: {
data: Partial<CompanyProfile>
onChange: (updates: Partial<CompanyProfile>) => void
}) {
const toggleMarket = (market: TargetMarket) => {
const current = data.targetMarkets || []
if (current.includes(market)) {
onChange({ targetMarkets: current.filter(m => m !== market) })
} else {
onChange({ targetMarkets: [...current, market] })
}
}
const countryStates = data.headquartersCountry ? STATES_BY_COUNTRY[data.headquartersCountry] : null
const stateLabel = countryStates?.label || 'Region / Provinz'
return (
<div className="space-y-8">
{/* Country */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Land des Hauptsitzes <span className="text-red-500">*</span>
</label>
<select
value={data.headquartersCountry || ''}
onChange={e => onChange({ headquartersCountry: e.target.value, headquartersCountryOther: '' })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Bitte w&auml;hlen...</option>
<option value="DE">Deutschland</option>
<option value="AT">&Ouml;sterreich</option>
<option value="CH">Schweiz</option>
<option value="LI">Liechtenstein</option>
<option value="LU">Luxemburg</option>
<option value="NL">Niederlande</option>
<option value="FR">Frankreich</option>
<option value="IT">Italien</option>
<option value="other">Anderes Land</option>
</select>
</div>
{data.headquartersCountry === 'other' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Land (Freitext)</label>
<input
type="text"
value={data.headquartersCountryOther || ''}
onChange={e => onChange({ headquartersCountryOther: e.target.value })}
placeholder="z.B. Vereinigtes K&ouml;nigreich"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
)}
{/* Street + House Number */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Stra&szlig;e und Hausnummer</label>
<input
type="text"
value={data.headquartersStreet || ''}
onChange={e => onChange({ headquartersStreet: e.target.value })}
placeholder="Musterstra&szlig;e 42"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{/* PLZ + City */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">PLZ</label>
<input
type="text"
value={data.headquartersZip || ''}
onChange={e => onChange({ headquartersZip: e.target.value })}
placeholder="10115"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">Stadt</label>
<input
type="text"
value={data.headquartersCity || ''}
onChange={e => onChange({ headquartersCity: e.target.value })}
placeholder="Berlin"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
{/* State / Bundesland / Kanton */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{stateLabel}</label>
{countryStates ? (
<select
value={data.headquartersState || ''}
onChange={e => onChange({ headquartersState: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Bitte w&auml;hlen...</option>
{countryStates.options.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
) : (
<input
type="text"
value={data.headquartersState || ''}
onChange={e => onChange({ headquartersState: e.target.value })}
placeholder="Region / Provinz"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-4">
Zielm&auml;rkte <span className="text-red-500">*</span>
<span className="text-gray-400 font-normal ml-2">Wo verkaufen/operieren Sie?</span>
</label>
<div className="space-y-3">
{Object.entries(TARGET_MARKET_LABELS).map(([value, { label, description }]) => (
<button
key={value}
type="button"
onClick={() => toggleMarket(value as TargetMarket)}
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
(data.targetMarkets || []).includes(value as TargetMarket)
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300'
}`}
>
<div className="font-medium text-gray-900">{label}</div>
<div className="text-sm text-gray-500">{description}</div>
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,253 @@
'use client'
import {
CompanyProfile,
MachineBuilderProfile,
MachineProductType,
AIIntegrationType,
HumanOversightLevel,
CriticalSector,
MACHINE_PRODUCT_TYPE_LABELS,
AI_INTEGRATION_TYPE_LABELS,
HUMAN_OVERSIGHT_LABELS,
CRITICAL_SECTOR_LABELS,
} from '@/lib/sdk/types'
const EMPTY_MACHINE_BUILDER: MachineBuilderProfile = {
productTypes: [], productDescription: '', productPride: '',
containsSoftware: false, containsFirmware: false, containsAI: false,
aiIntegrationType: [], hasSafetyFunction: false, safetyFunctionDescription: '',
autonomousBehavior: false, humanOversightLevel: 'full',
isNetworked: false, hasRemoteAccess: false, hasOTAUpdates: false, updateMechanism: '',
exportMarkets: [], criticalSectorClients: false, criticalSectors: [],
oemClients: false, ceMarkingRequired: false, existingCEProcess: false, hasRiskAssessment: false,
}
export function StepMachineBuilder({
data,
onChange,
}: {
data: Partial<CompanyProfile>
onChange: (updates: Partial<CompanyProfile>) => void
}) {
const mb = data.machineBuilder || EMPTY_MACHINE_BUILDER
const updateMB = (updates: Partial<MachineBuilderProfile>) => {
onChange({ machineBuilder: { ...mb, ...updates } })
}
const toggleProductType = (type: MachineProductType) => {
const current = mb.productTypes || []
updateMB({ productTypes: current.includes(type) ? current.filter(t => t !== type) : [...current, type] })
}
const toggleAIType = (type: AIIntegrationType) => {
const current = mb.aiIntegrationType || []
updateMB({ aiIntegrationType: current.includes(type) ? current.filter(t => t !== type) : [...current, type] })
}
const toggleCriticalSector = (sector: CriticalSector) => {
const current = mb.criticalSectors || []
updateMB({ criticalSectors: current.includes(sector) ? current.filter(s => s !== sector) : [...current, sector] })
}
return (
<div className="space-y-8">
{/* Block 1: Product description */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-1">Erzaehlen Sie uns von Ihrer Anlage</h3>
<p className="text-sm text-gray-500 mb-4">Je besser wir Ihr Produkt verstehen, desto praeziser koennen wir die relevanten Vorschriften identifizieren.</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Was baut Ihr Unternehmen? <span className="text-red-500">*</span></label>
<textarea value={mb.productDescription} onChange={e => updateMB({ productDescription: e.target.value })} placeholder="z.B. Wir bauen automatisierte Pruefstaende fuer die Qualitaetskontrolle in der Automobilindustrie..." rows={3} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Was macht Ihre Anlage besonders?</label>
<textarea value={mb.productPride} onChange={e => updateMB({ productPride: e.target.value })} placeholder="z.B. Unsere Anlage kann 500 Teile/Stunde mit 99.9% Erkennungsrate pruefen..." rows={2} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Produkttyp <span className="text-gray-400">(Mehrfachauswahl)</span></label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{Object.entries(MACHINE_PRODUCT_TYPE_LABELS).map(([value, label]) => (
<button key={value} type="button" onClick={() => toggleProductType(value as MachineProductType)}
className={`px-4 py-3 rounded-lg border-2 text-sm font-medium transition-all ${mb.productTypes.includes(value as MachineProductType) ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 hover:border-purple-300 text-gray-700'}`}>
{label}
</button>
))}
</div>
</div>
</div>
</div>
{/* Block 2: Software & KI */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Software & KI in Ihrem Produkt</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ key: 'containsSoftware', label: 'Enthaelt Software', desc: 'Anwendungssoftware in der Maschine' },
{ key: 'containsFirmware', label: 'Enthaelt Firmware', desc: 'Embedded Software / Steuerung' },
{ key: 'containsAI', label: 'Enthaelt KI/ML', desc: 'Kuenstliche Intelligenz / Machine Learning' },
].map(item => (
<label key={item.key} className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${(mb as any)[item.key] ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
<input type="checkbox" checked={(mb as any)[item.key] ?? false} onChange={e => updateMB({ [item.key]: e.target.checked } as any)} className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
<div>
<div className="font-medium text-gray-900 text-sm">{item.label}</div>
<div className="text-xs text-gray-500">{item.desc}</div>
</div>
</label>
))}
</div>
{mb.containsAI && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Art der KI-Integration</label>
<div className="grid grid-cols-2 gap-3">
{Object.entries(AI_INTEGRATION_TYPE_LABELS).map(([value, label]) => (
<button key={value} type="button" onClick={() => toggleAIType(value as AIIntegrationType)}
className={`px-4 py-2 rounded-lg border text-sm transition-all ${mb.aiIntegrationType.includes(value as AIIntegrationType) ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 hover:border-purple-300 text-gray-700'}`}>
{label}
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.hasSafetyFunction ? 'border-red-400 bg-red-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="checkbox" checked={mb.hasSafetyFunction} onChange={e => updateMB({ hasSafetyFunction: e.target.checked })} className="mt-1 w-5 h-5 text-red-600 rounded focus:ring-red-500" />
<div>
<div className="font-medium text-gray-900 text-sm">Sicherheitsrelevante Funktion</div>
<div className="text-xs text-gray-500">KI/SW hat sicherheitsrelevante Funktion</div>
</div>
</label>
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.autonomousBehavior ? 'border-amber-400 bg-amber-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="checkbox" checked={mb.autonomousBehavior} onChange={e => updateMB({ autonomousBehavior: e.target.checked })} className="mt-1 w-5 h-5 text-amber-600 rounded focus:ring-amber-500" />
<div>
<div className="font-medium text-gray-900 text-sm">Autonomes Verhalten</div>
<div className="text-xs text-gray-500">System lernt oder handelt eigenstaendig</div>
</div>
</label>
</div>
{mb.hasSafetyFunction && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung der Sicherheitsfunktion</label>
<textarea value={mb.safetyFunctionDescription} onChange={e => updateMB({ safetyFunctionDescription: e.target.value })} placeholder="z.B. KI-Vision ueberwacht den Schutzbereich und stoppt den Roboter bei Personenerkennung..." rows={2} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Human Oversight Level</label>
<select value={mb.humanOversightLevel} onChange={e => updateMB({ humanOversightLevel: e.target.value as HumanOversightLevel })} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
{Object.entries(HUMAN_OVERSIGHT_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
</div>
</div>
{/* Block 3: Konnektivitaet & Updates */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Konnektivitaet & Updates</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
{[
{ key: 'isNetworked', label: 'Vernetzt', desc: 'Maschine ist mit Netzwerk verbunden' },
{ key: 'hasRemoteAccess', label: 'Remote-Zugriff', desc: 'Fernwartung / Remote-Zugang' },
{ key: 'hasOTAUpdates', label: 'OTA-Updates', desc: 'Drahtlose Software-/Firmware-Updates' },
].map(item => (
<label key={item.key} className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${(mb as any)[item.key] ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
<input type="checkbox" checked={(mb as any)[item.key] ?? false} onChange={e => updateMB({ [item.key]: e.target.checked } as any)} className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
<div>
<div className="font-medium text-gray-900 text-sm">{item.label}</div>
<div className="text-xs text-gray-500">{item.desc}</div>
</div>
</label>
))}
</div>
{(mb.hasOTAUpdates || mb.hasRemoteAccess) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Wie werden Updates eingespielt?</label>
<input type="text" value={mb.updateMechanism} onChange={e => updateMB({ updateMechanism: e.target.value })} placeholder="z.B. VPN-gesicherter Remote-Zugang mit manueller Freigabe..." className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
)}
</div>
{/* Block 4: Markt & Kunden */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Markt & Kunden</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.criticalSectorClients ? 'border-red-400 bg-red-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="checkbox" checked={mb.criticalSectorClients} onChange={e => updateMB({ criticalSectorClients: e.target.checked })} className="mt-1 w-5 h-5 text-red-600 rounded focus:ring-red-500" />
<div>
<div className="font-medium text-gray-900 text-sm">Liefert an KRITIS-Betreiber</div>
<div className="text-xs text-gray-500">Kunden in kritischer Infrastruktur</div>
</div>
</label>
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.oemClients ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="checkbox" checked={mb.oemClients} onChange={e => updateMB({ oemClients: e.target.checked })} className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
<div>
<div className="font-medium text-gray-900 text-sm">OEM-Zulieferer</div>
<div className="text-xs text-gray-500">Liefern Komponenten an andere Hersteller</div>
</div>
</label>
</div>
{mb.criticalSectorClients && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">Kritische Sektoren Ihrer Kunden</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{Object.entries(CRITICAL_SECTOR_LABELS).map(([value, label]) => (
<button key={value} type="button" onClick={() => toggleCriticalSector(value as CriticalSector)}
className={`px-3 py-2 rounded-lg border text-sm transition-all ${mb.criticalSectors.includes(value as CriticalSector) ? 'border-red-400 bg-red-50 text-red-700' : 'border-gray-200 hover:border-gray-300 text-gray-700'}`}>
{label}
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.ceMarkingRequired ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="checkbox" checked={mb.ceMarkingRequired} onChange={e => updateMB({ ceMarkingRequired: e.target.checked })} className="mt-1 w-5 h-5 text-blue-600 rounded focus:ring-blue-500" />
<div>
<div className="font-medium text-gray-900 text-sm">CE-Kennzeichnung erforderlich</div>
<div className="text-xs text-gray-500">Produkt benoetigt CE-Zertifizierung</div>
</div>
</label>
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.existingCEProcess ? 'border-green-400 bg-green-50' : 'border-gray-200 hover:border-gray-300'}`}>
<input type="checkbox" checked={mb.existingCEProcess} onChange={e => updateMB({ existingCEProcess: e.target.checked })} className="mt-1 w-5 h-5 text-green-600 rounded focus:ring-green-500" />
<div>
<div className="font-medium text-gray-900 text-sm">Bestehender CE-Prozess</div>
<div className="text-xs text-gray-500">Bereits ein CE-Verfahren etabliert</div>
</div>
</label>
</div>
{mb.ceMarkingRequired && (
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.hasRiskAssessment ? 'border-green-400 bg-green-50' : 'border-red-400 bg-red-50'}`}>
<input type="checkbox" checked={mb.hasRiskAssessment} onChange={e => updateMB({ hasRiskAssessment: e.target.checked })} className="mt-1 w-5 h-5 text-green-600 rounded focus:ring-green-500" />
<div>
<div className="font-medium text-gray-900 text-sm">Bestehende Risikobeurteilung</div>
<div className="text-xs text-gray-500">
{mb.hasRiskAssessment ? 'Risikobeurteilung vorhanden' : 'Keine bestehende Risikobeurteilung - IACE hilft Ihnen dabei!'}
</div>
</div>
</label>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,343 @@
'use client'
import { useState } from 'react'
import { CompanyProfile } from '@/lib/sdk/types'
import { ProcessingActivity, ActivityTemplate, ActivityDepartment } from './types'
import { ALL_DATA_CATEGORIES, ALL_SPECIAL_CATEGORIES, getRelevantDepartments } from './activity-data'
function CategoryCheckbox({
cat,
activity,
variant,
template,
expandedInfoCat,
onToggleCategory,
onToggleInfo,
}: {
cat: { id: string; label: string; desc: string; info: string }
activity: ProcessingActivity
variant: 'normal' | 'extra' | 'art9' | 'art9-extra'
template?: ActivityTemplate | null
expandedInfoCat: string | null
onToggleCategory: (activityId: string, categoryId: string) => void
onToggleInfo: (key: string | null) => void
}) {
const infoText = template?.categoryInfo?.[cat.id] || cat.info
const isInfoExpanded = expandedInfoCat === `${activity.id}-${cat.id}`
const colorClasses = variant.startsWith('art9')
? { check: 'text-red-600 focus:ring-red-500', hover: 'hover:bg-red-100', text: variant === 'art9-extra' ? 'text-gray-500' : 'text-gray-700' }
: { check: 'text-purple-600 focus:ring-purple-500', hover: 'hover:bg-gray-100', text: variant === 'extra' ? 'text-gray-500' : 'text-gray-700' }
const aufbewahrungIdx = infoText.indexOf('Aufbewahrung:')
const loeschfristIdx = infoText.indexOf('L\u00F6schfrist')
const speicherdauerIdx = infoText.indexOf('Speicherdauer:')
const retentionIdx = [aufbewahrungIdx, loeschfristIdx, speicherdauerIdx].filter(i => i >= 0).sort((a, b) => a - b)[0] ?? -1
const hasRetention = retentionIdx >= 0
const mainText = hasRetention ? infoText.slice(0, retentionIdx).replace(/\.\s*$/, '') : infoText
const retentionText = hasRetention ? infoText.slice(retentionIdx) : ''
return (
<div key={cat.id}>
<label className={`flex items-center gap-2 text-xs p-1.5 rounded ${colorClasses.hover} cursor-pointer`}>
<input type="checkbox" checked={activity.data_categories.includes(cat.id)} onChange={() => onToggleCategory(activity.id, cat.id)} className={`w-3.5 h-3.5 ${colorClasses.check} rounded`} />
<span className={colorClasses.text}>{cat.label}</span>
<button
type="button"
onClick={e => { e.preventDefault(); e.stopPropagation(); onToggleInfo(isInfoExpanded ? null : `${activity.id}-${cat.id}`) }}
className="ml-auto w-4 h-4 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 text-gray-500 text-[10px] font-bold flex-shrink-0"
title={infoText}
>
i
</button>
</label>
{isInfoExpanded && (
<div className="ml-7 mt-1 mb-1 px-2 py-1.5 bg-blue-50 border border-blue-100 rounded text-[11px] text-blue-800">
{hasRetention ? (
<>
<span>{mainText}</span>
<span className="block mt-1 px-1.5 py-0.5 bg-amber-50 border border-amber-200 rounded text-amber-800">
<span className="mr-1">&#128339;</span>{retentionText}
</span>
</>
) : infoText}
</div>
)}
</div>
)
}
function ActivityDetail({
activity,
template,
showExtraCategories,
expandedInfoCat,
onToggleCategory,
onToggleInfo,
onToggleExtraCategories,
onUpdateActivity,
onRemoveActivity,
}: {
activity: ProcessingActivity
template: ActivityTemplate | null
showExtraCategories: Set<string>
expandedInfoCat: string | null
onToggleCategory: (activityId: string, categoryId: string) => void
onToggleInfo: (key: string | null) => void
onToggleExtraCategories: (activityId: string) => void
onUpdateActivity: (id: string, updates: Partial<ProcessingActivity>) => void
onRemoveActivity: (id: string) => void
}) {
const primaryIds = new Set(template?.primary_categories || [])
const art9Ids = new Set(template?.art9_relevant || [])
const primaryCats = ALL_DATA_CATEGORIES.filter(c => primaryIds.has(c.id))
const extraCats = ALL_DATA_CATEGORIES.filter(c => !primaryIds.has(c.id))
const relevantArt9 = ALL_SPECIAL_CATEGORIES.filter(c => art9Ids.has(c.id))
const otherArt9 = ALL_SPECIAL_CATEGORIES.filter(c => !art9Ids.has(c.id))
const showingExtra = showExtraCategories.has(activity.id)
const isCustom = !template || activity.custom
return (
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-4">
{template?.legalHint && (
<div className="flex items-start gap-2 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
<span className="text-amber-600 text-sm mt-0.5">&#9888;</span>
<span className="text-xs text-amber-800">{template.legalHint}</span>
</div>
)}
{isCustom && (
<div className="grid grid-cols-1 gap-3">
<input type="text" value={activity.name} onChange={e => onUpdateActivity(activity.id, { name: e.target.value })} placeholder="Name der Verarbeitungst\u00E4tigkeit" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<input type="text" value={activity.purpose} onChange={e => onUpdateActivity(activity.id, { purpose: e.target.value })} placeholder="Zweck der Verarbeitung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
)}
{template?.hasServiceProvider && (
<div className="bg-blue-50 rounded-lg p-3 border border-blue-100 space-y-2">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<input
type="checkbox"
checked={activity.usesServiceProvider || false}
onChange={e => onUpdateActivity(activity.id, {
usesServiceProvider: e.target.checked,
...(!e.target.checked ? { serviceProviderName: '' } : {})
})}
className="w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500"
/>
<span className="text-blue-800 font-medium">Externer Dienstleister wird eingesetzt</span>
</label>
{activity.usesServiceProvider && (
<div className="ml-6">
<input
type="text"
value={activity.serviceProviderName || ''}
onChange={e => onUpdateActivity(activity.id, { serviceProviderName: e.target.value })}
placeholder="Name des Dienstleisters (optional)"
className="w-full px-3 py-1.5 border border-blue-200 rounded text-xs focus:ring-2 focus:ring-blue-400 focus:border-transparent bg-white"
/>
<p className="text-[10px] text-blue-600 mt-1">Wird als Auftragsverarbeiter (AVV) im VVT erfasst.</p>
</div>
)}
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">Betroffene Datenkategorien</label>
<div className="grid grid-cols-2 gap-1.5">
{(isCustom ? ALL_DATA_CATEGORIES : primaryCats).map(cat =>
<CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="normal" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />
)}
</div>
</div>
{!isCustom && extraCats.length > 0 && (
<div>
<button type="button" onClick={() => onToggleExtraCategories(activity.id)} className="text-xs text-purple-600 hover:text-purple-800">
{showingExtra ? '\u25BE Weitere Kategorien ausblenden' : `\u25B8 Weitere ${extraCats.length} Kategorien anzeigen`}
</button>
{showingExtra && (
<div className="grid grid-cols-2 gap-1.5 mt-2">
{extraCats.map(cat => <CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="extra" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />)}
</div>
)}
</div>
)}
{(isCustom ? ALL_SPECIAL_CATEGORIES.length > 0 : relevantArt9.length > 0) && (
<div className="bg-red-50 rounded-lg p-3 border border-red-100">
<label className="block text-xs font-medium text-red-700 mb-2">
Besondere Kategorien (Art. 9 DSGVO)
</label>
<div className="grid grid-cols-2 gap-1.5">
{(isCustom ? ALL_SPECIAL_CATEGORIES : relevantArt9).map(cat =>
<CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="art9" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />
)}
</div>
{!isCustom && otherArt9.length > 0 && showingExtra && (
<div className="grid grid-cols-2 gap-1.5 mt-2 pt-2 border-t border-red-100">
{otherArt9.map(cat => <CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="art9-extra" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />)}
</div>
)}
</div>
)}
<button type="button" onClick={() => onRemoveActivity(activity.id)} className="text-xs text-red-500 hover:text-red-700">
Verarbeitungst&auml;tigkeit entfernen
</button>
</div>
)
}
export function StepProcessing({
data,
onChange,
}: {
data: Partial<CompanyProfile> & { processingSystems?: ProcessingActivity[] }
onChange: (updates: Record<string, unknown>) => void
}) {
const activities: ProcessingActivity[] = (data as any).processingSystems || []
const industry = data.industry || []
const [expandedActivity, setExpandedActivity] = useState<string | null>(null)
const [collapsedDepts, setCollapsedDepts] = useState<Set<string>>(new Set())
const [showExtraCats, setShowExtraCats] = useState<Set<string>>(new Set())
const [expandedInfoCat, setExpandedInfoCat] = useState<string | null>(null)
const departments = getRelevantDepartments(industry, data.businessModel, data.companySize)
const activeIds = new Set(activities.map(a => a.id))
const toggleActivity = (template: ActivityTemplate, deptId: string) => {
if (activeIds.has(template.id)) {
onChange({ processingSystems: activities.filter(a => a.id !== template.id) })
} else {
onChange({
processingSystems: [...activities, {
id: template.id, name: template.name, purpose: template.purpose,
data_categories: [...template.primary_categories],
legal_basis: template.default_legal_basis, department: deptId,
}],
})
}
}
const updateActivity = (id: string, updates: Partial<ProcessingActivity>) => {
onChange({ processingSystems: activities.map(a => a.id === id ? { ...a, ...updates } : a) })
}
const toggleDataCategory = (activityId: string, categoryId: string) => {
const activity = activities.find(a => a.id === activityId)
if (!activity) return
const cats = activity.data_categories.includes(categoryId)
? activity.data_categories.filter(c => c !== categoryId)
: [...activity.data_categories, categoryId]
updateActivity(activityId, { data_categories: cats })
}
const toggleDeptCollapse = (deptId: string) => {
setCollapsedDepts(prev => { const next = new Set(prev); if (next.has(deptId)) next.delete(deptId); else next.add(deptId); return next })
}
const toggleExtraCategories = (activityId: string) => {
setShowExtraCats(prev => { const next = new Set(prev); if (next.has(activityId)) next.delete(activityId); else next.add(activityId); return next })
}
const addCustomActivity = () => {
const id = `custom_${Date.now()}`
onChange({ processingSystems: [...activities, { id, name: '', purpose: '', data_categories: [], legal_basis: 'contract', custom: true }] })
setExpandedActivity(id)
}
const removeActivity = (id: string) => {
onChange({ processingSystems: activities.filter(a => a.id !== id) })
if (expandedActivity === id) setExpandedActivity(null)
}
const deptActivityCount = (dept: ActivityDepartment) => dept.activities.filter(a => activeIds.has(a.id)).length
return (
<div className="space-y-8">
<div>
<h3 className="text-sm font-medium text-gray-700 mb-1">Verarbeitungst&auml;tigkeiten</h3>
<p className="text-xs text-gray-500 mb-4">
W&auml;hlen Sie pro Abteilung aus, welche Verarbeitungen stattfinden. Diese bilden die Grundlage f&uuml;r Ihr Verarbeitungsverzeichnis (VVT) nach Art. 30 DSGVO.
</p>
<div className="space-y-4">
{departments.map(dept => {
const isCollapsed = collapsedDepts.has(dept.id)
const activeCount = deptActivityCount(dept)
return (
<div key={dept.id} className="border border-gray-200 rounded-lg overflow-hidden">
<button type="button" onClick={() => toggleDeptCollapse(dept.id)} className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left">
<span className="text-base">{dept.icon}</span>
<span className="text-sm font-medium text-gray-900 flex-1">{dept.name}</span>
{activeCount > 0 && <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{activeCount} aktiv</span>}
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{!isCollapsed && (
<div className="p-3 space-y-2">
{dept.activities.map(template => {
const isActive = activeIds.has(template.id)
const activity = activities.find(a => a.id === template.id)
const isExpanded = expandedActivity === template.id
return (
<div key={template.id}>
<div
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${isActive ? 'border-purple-500 bg-purple-50' : 'border-gray-100 hover:border-purple-300'}`}
onClick={() => { if (!isActive) { toggleActivity(template, dept.id); setExpandedActivity(template.id) } else { setExpandedActivity(isExpanded ? null : template.id) } }}
>
<input type="checkbox" checked={isActive} onChange={e => { e.stopPropagation(); toggleActivity(template, dept.id) }} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">{template.name}</span>
{template.legalHint && <span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium whitespace-nowrap">Pflicht</span>}
{template.hasServiceProvider && <span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded font-medium whitespace-nowrap">AVV-relevant</span>}
</div>
<p className="text-xs text-gray-500 truncate">{template.purpose}</p>
</div>
{isActive && <span className="text-xs text-purple-600 flex-shrink-0">{activity?.data_categories.length || 0} Kat.</span>}
{isActive && (
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</div>
{isActive && isExpanded && activity && (
<ActivityDetail activity={activity} template={template} showExtraCategories={showExtraCats} expandedInfoCat={expandedInfoCat} onToggleCategory={toggleDataCategory} onToggleInfo={setExpandedInfoCat} onToggleExtraCategories={toggleExtraCategories} onUpdateActivity={updateActivity} onRemoveActivity={removeActivity} />
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
{activities.filter(a => a.custom).map(activity => (
<div key={activity.id} className="mt-2">
<div className="flex items-center gap-3 p-3 rounded-lg border-2 border-purple-500 bg-purple-50 cursor-pointer" onClick={() => setExpandedActivity(expandedActivity === activity.id ? null : activity.id)}>
<span className="w-4 h-4 flex items-center justify-center text-purple-600 flex-shrink-0 text-sm">+</span>
<div className="flex-1 min-w-0"><span className="text-sm font-medium text-gray-900">{activity.name || 'Neue Verarbeitungst\u00E4tigkeit'}</span></div>
<svg className={`w-4 h-4 text-gray-400 transition-transform ${expandedActivity === activity.id ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{expandedActivity === activity.id && (
<ActivityDetail activity={activity} template={null} showExtraCategories={showExtraCats} expandedInfoCat={expandedInfoCat} onToggleCategory={toggleDataCategory} onToggleInfo={setExpandedInfoCat} onToggleExtraCategories={toggleExtraCategories} onUpdateActivity={updateActivity} onRemoveActivity={removeActivity} />
)}
</div>
))}
<button type="button" onClick={addCustomActivity} className="w-full mt-3 px-3 py-2 text-sm text-purple-700 bg-purple-50 border-2 border-dashed border-purple-200 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-colors">
+ Eigene Verarbeitungst&auml;tigkeit hinzuf&uuml;gen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,209 @@
import { ActivityDepartment } from './types'
// DSGVO-Standard Datenkategorien
export const ALL_DATA_CATEGORIES = [
{ id: 'stammdaten', label: 'Stammdaten', desc: 'Name, Geburtsdatum, Geschlecht', info: 'Vor- und Nachname, Geburtsdatum, Geschlecht, Anrede, Titel, Familienstand, Staatsangehörigkeit, Personalnummer, Kundennummer' },
{ id: 'kontaktdaten', label: 'Kontaktdaten', desc: 'E-Mail, Telefon, Adresse', info: 'E-Mail-Adresse, Telefonnummer, Mobilnummer, Postanschrift, Faxnummer, Messenger-IDs der betroffenen Personen' },
{ id: 'vertragsdaten', label: 'Vertragsdaten', desc: 'Vertragsnummer, Laufzeit, Konditionen', info: 'Vertragsnummer, Vertragsbeginn/-ende, Laufzeit, Konditionen, Kündigungsfristen, Vertragsgegenstand, Bestellhistorie' },
{ id: 'zahlungsdaten', label: 'Zahlungs-/Bankdaten', desc: 'IBAN, Kreditkarte, Rechnungen', info: 'IBAN, BIC, Kontoinhaber, Kreditkartennummer, Rechnungsbeträge, Zahlungshistorie, Steuer-ID, USt-IdNr.' },
{ id: 'beschaeftigtendaten', label: 'Beschäftigtendaten', desc: 'Gehalt, Arbeitszeiten, Urlaub', info: 'Gehalt/Lohn, Steuerklasse, SV-Nummer, Krankenkasse (z.B. AOK, TK), Arbeitszeiten, Urlaubstage, Abwesenheiten, Beurteilungen, Eintrittsdatum. Aufbewahrung: i.d.R. 3 Jahre nach Austritt (§ 195 BGB), Lohndaten 8 Jahre (§ 147 AO)' },
{ id: 'kommunikation', label: 'Kommunikationsdaten', desc: 'E-Mail-Inhalte, Chat-Verläufe', info: 'E-Mail-Inhalte und -Metadaten, Chat-Nachrichten, Gesprächsprotokolle, Support-Tickets, Briefkorrespondenz' },
{ id: 'nutzungsdaten', label: 'Nutzungs-/Logdaten', desc: 'IP-Adressen, Login-Zeiten, Klicks', info: 'IP-Adressen, Login-Zeitpunkte, Seitenaufrufe, Klickverhalten, Geräteinformationen, Browser-Typ, Session-Dauer' },
{ id: 'standortdaten', label: 'Standortdaten', desc: 'GPS, Check-in, Lieferadressen', info: 'GPS-Koordinaten, Check-in/Check-out-Zeiten, Lieferadressen, Reiserouten, WLAN-Standortbestimmung' },
{ id: 'bilddaten', label: 'Bild-/Videodaten', desc: 'Fotos, Videoaufnahmen, Profilbilder', info: 'Profilfotos, Ausweiskopien, Videoaufnahmen (Überwachung), Bewerbungsfotos, Schulungsvideos' },
{ id: 'bewerberdaten', label: 'Bewerberdaten', desc: 'Lebenslauf, Zeugnisse, Anschreiben', info: 'Lebenslauf, Anschreiben, Zeugnisse, Referenzen, Gehaltsvorstellungen, Verfügbarkeit, Bewerbungsquelle. Löschfrist bei Absage: max. 6 Monate (AGG §§ 15, 21)' },
{ id: 'qualifikationsdaten', label: 'Qualifikations-/Schulungsdaten', desc: 'Fortbildungen, Zertifikate, Abschlüsse', info: 'Besuchte Seminare und Schulungen, Zertifikate, Abschlüsse, Qualifikationsnachweise, Schulungsdaten und -ergebnisse, Weiterbildungshistorie' },
] as const
export const ALL_SPECIAL_CATEGORIES = [
{ id: 'gesundheit', label: 'Gesundheitsdaten', desc: 'Krankheitstage, Atteste, Diagnosen', info: 'Krankheitstage, AU-Bescheinigungen, Diagnosen, Behinderungsgrad (GdB), BEM-Daten, arbeitsmedizinische Untersuchungen, Impfstatus, Allergien. Auch AU ohne Diagnose = Gesundheitsdatum (LDI NRW). Schwangerschaft, Allergien, Online-Arzneimittelbestellung (EuGH C-21/23). NICHT: Krankenkassenname (z.B. AOK, TK) — das sind normale Beschäftigtendaten.' },
{ id: 'biometrie', label: 'Biometrische Daten', desc: 'Fingerabdruck, Gesichtserkennung', info: 'Fingerabdruck, Gesichtserkennung, Iris-Scan, Stimmerkennung, Handvenenscan. Nur wenn zur eindeutigen Identifizierung verwendet (ErwGr. 51). Einfaches Passfoto = kein biometrisches Datum.' },
{ id: 'religion', label: 'Religion', desc: 'Konfession, Kirchensteuer', info: 'Konfession/Religionszugehörigkeit (relevant für Kirchensteuer auf Lohnabrechnung). Auch indirekt: Kantinenbestellung halal/koscher (EuGH C-184/20 weite Auslegung). Praktisch jedes Unternehmen mit Beschäftigten verarbeitet diese Daten über die Gehaltsabrechnung.' },
{ id: 'gewerkschaft', label: 'Gewerkschaftszugehörigkeit', desc: 'Mitgliedschaft', info: 'Gewerkschaftsmitgliedschaft, Betriebsratszugehörigkeit, Tarifzugehörigkeit' },
{ id: 'genetik', label: 'Genetische Daten', desc: 'DNA, Erbkrankheiten', info: 'DNA-Analysen, genetische Prädispositionen, Erbkrankheitsrisiken (nur in Spezialfällen relevant)' },
] as const
// ── Universelle Abteilungen (immer sichtbar) ──
const UNIVERSAL_DEPARTMENTS: ActivityDepartment[] = [
{
id: 'personal', name: 'Personal / HR', icon: '\uD83D\uDC65',
activities: [
{ id: 'personalverwaltung', name: 'Personalverwaltung', purpose: 'Verwaltung von Beschäftigtendaten für das Arbeitsverhältnis', primary_categories: ['stammdaten', 'kontaktdaten', 'beschaeftigtendaten', 'zahlungsdaten'], art9_relevant: ['gesundheit', 'religion', 'gewerkschaft'], default_legal_basis: 'contract', categoryInfo: { stammdaten: 'Vor-/Nachname, Geburtsdatum, Geschlecht, Familienstand, Staatsangehörigkeit, Personalnummer', kontaktdaten: 'Privat- und Dienstadresse, Telefonnummern, dienstliche E-Mail, Notfallkontakt', beschaeftigtendaten: 'Steuerklasse, SV-Nummer, Krankenkasse (z.B. AOK, TK \u2014 kein Gesundheitsdatum!), Eintrittsdatum, Arbeitszeit, Urlaubstage. Aufbewahrung: 3 Jahre nach Austritt (\u00A7 195 BGB)', zahlungsdaten: 'IBAN f\u00FCr Gehaltsauszahlung, Verm\u00F6genswirksame Leistungen, Pf\u00E4ndungsdaten' } },
{ id: 'lohnbuchhaltung', name: 'Lohn- und Gehaltsabrechnung', purpose: 'Berechnung und Auszahlung von L\u00F6hnen und Geh\u00E4ltern', primary_categories: ['beschaeftigtendaten', 'zahlungsdaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'legal', hasServiceProvider: true, categoryInfo: { beschaeftigtendaten: 'Gehalt, Zulagen, Pr\u00E4mien, Steuerklasse, SV-Nummer, Krankenkasse, Kirchensteuermerkmal. Aufbewahrung: Lohnabrechnungen 8 Jahre (\u00A7 147 AO), Lohnsteuer 6 Jahre (\u00A7 41 EStG). Hinweis: Gesundheits- und Religionsdaten werden bereits unter Personalverwaltung als Art. 9-Kategorien erfasst.', zahlungsdaten: 'IBAN, Bankverbindung, Gehaltsabrechnungen, Pf\u00E4ndungsbetr\u00E4ge. Aufbewahrung: 8 Jahre (\u00A7 147 AO)' } },
{ id: 'bewerbermanagement', name: 'Bewerbermanagement', purpose: 'Entgegennahme, Pr\u00FCfung und Bearbeitung von Bewerbungen', primary_categories: ['bewerberdaten', 'stammdaten', 'kontaktdaten', 'kommunikation', 'qualifikationsdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'consent', categoryInfo: { bewerberdaten: 'Lebenslauf, Anschreiben, Zeugnisse, Referenzen, Gehaltsvorstellungen. L\u00F6schfrist bei Absage: max. 6 Monate (AGG \u00A7\u00A7 15, 21)', kontaktdaten: 'Privatadresse, E-Mail, Telefonnummer des Bewerbers', kommunikation: 'Bewerbungskorrespondenz, Einladungen, Absageschreiben' } },
{ id: 'arbeitszeiterfassung', name: 'Arbeitszeiterfassung', purpose: 'Erfassung und Dokumentation der Arbeitszeiten', primary_categories: ['beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'legal', legalHint: 'Gesetzlich vorgeschrieben (\u00A7 3 ArbZG). Fehlende Arbeitszeiterfassung ist ein Compliance-Risiko.', categoryInfo: { beschaeftigtendaten: 'Beginn/Ende der Arbeitszeit, Pausen, \u00DCberstunden, Ruhezeiten. Aufbewahrung: mind. 2 Jahre (\u00A7 16 Abs. 2 ArbZG). Nicht f\u00FCr Leistungskontrolle verwenden!' } },
{ id: 'weiterbildung', name: 'Fort- und Weiterbildung', purpose: 'Verwaltung von Schulungen und Weiterbildungsma\u00DFnahmen', primary_categories: ['qualifikationsdaten', 'beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
],
},
{
id: 'finanzen', name: 'Finanzen / Buchhaltung', icon: '\uD83D\uDCB0',
activities: [
{ id: 'finanzbuchhaltung', name: 'Finanzbuchhaltung', purpose: 'Buchf\u00FChrung, Rechnungsstellung, steuerliche Dokumentation', primary_categories: ['stammdaten', 'zahlungsdaten', 'vertragsdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'legal', categoryInfo: { zahlungsdaten: 'Rechnungsbetr\u00E4ge, IBAN, Buchungsbelege, USt-IdNr. Aufbewahrung: 8 Jahre (\u00A7 147 AO)', vertragsdaten: 'Vertragsnummer, Konditionen, Bestellhistorie. Aufbewahrung: Handelskorrespondenz 6 Jahre (\u00A7 257 HGB)', kontaktdaten: 'Rechnungsadresse, Ansprechpartner in der Debitorenbuchhaltung' } },
{ id: 'zahlungsverkehr', name: 'Zahlungsverkehr', purpose: 'Abwicklung von ein- und ausgehenden Zahlungen', primary_categories: ['zahlungsdaten', 'stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'mahnwesen', name: 'Mahnwesen / Inkasso', purpose: '\u00DCberwachung offener Forderungen und Mahnverfahren', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'interest' },
{ id: 'reisekostenabrechnung', name: 'Reisekostenabrechnung', purpose: 'Abrechnung und Erstattung von Dienstreisekosten', primary_categories: ['beschaeftigtendaten', 'zahlungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
],
},
{
id: 'vertrieb', name: 'Vertrieb / Sales', icon: '\uD83D\uDCC8',
activities: [
{ id: 'crm', name: 'CRM / Kundenverwaltung', purpose: 'Verwaltung von Kundenbeziehungen, Kontakthistorie, Verkaufschancen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract', categoryInfo: { stammdaten: 'Firmenname, Ansprechpartner-Name, Titel, Position, Kundennummer', kontaktdaten: 'Gesch\u00E4ftliche E-Mail, Telefon, B\u00FCroadresse des Ansprechpartners. B2B-Kontaktdaten sind personenbezogene Daten \u2014 Art. 13 DSGVO Informationspflicht gilt!', kommunikation: 'E-Mail-Korrespondenz, Gespr\u00E4chsnotizen, Support-Tickets, Meeting-Protokolle' } },
{ id: 'angebotserstellung', name: 'Angebotserstellung', purpose: 'Erstellung und Nachverfolgung von Angeboten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'vertragsmanagement', name: 'Vertragsmanagement', purpose: 'Verwaltung, Archivierung und Nachverfolgung von Vertr\u00E4gen', primary_categories: ['vertragsdaten', 'stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'contract' },
],
},
{
id: 'marketing', name: 'Marketing', icon: '\uD83D\uDCE3',
activities: [
{ id: 'newsletter', name: 'Newsletter / E-Mail-Marketing', purpose: 'Versand von Newslettern und E-Mail-Marketing an Abonnenten', primary_categories: ['kontaktdaten', 'nutzungsdaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'consent' },
{ id: 'website_tracking', name: 'Website-Tracking / Analytics', purpose: 'Analyse des Nutzerverhaltens auf der Website mittels Tracking-Tools', primary_categories: ['nutzungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'consent' },
{ id: 'social_media', name: 'Social-Media-Marketing', purpose: 'Betrieb von Unternehmensprofilen und Werbekampagnen', primary_categories: ['kontaktdaten', 'nutzungsdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'consent' },
{ id: 'consent_management', name: 'Consent-Management (Cookies)', purpose: 'Verwaltung der Einwilligungen f\u00FCr Cookies und Tracking', primary_categories: ['nutzungsdaten'], art9_relevant: [], default_legal_basis: 'consent' },
],
},
{
id: 'it', name: 'IT / Administration', icon: '\uD83D\uDDA5\uFE0F',
activities: [
{ id: 'zugangsverwaltung', name: 'Zugangsverwaltung (IAM)', purpose: 'Verwaltung von Benutzerkonten, Passw\u00F6rtern und Zugriffsrechten', primary_categories: ['beschaeftigtendaten', 'stammdaten', 'nutzungsdaten'], art9_relevant: ['biometrie'], default_legal_basis: 'contract' },
{ id: 'email_kommunikation', name: 'E-Mail-Kommunikation', purpose: 'Gesch\u00E4ftliche E-Mail-Korrespondenz', primary_categories: ['kontaktdaten', 'kommunikation', 'stammdaten'], art9_relevant: [], default_legal_basis: 'interest' },
{ id: 'datensicherung', name: 'Datensicherung / Backup', purpose: 'Sicherung von Unternehmensdaten zum Schutz vor Datenverlust', primary_categories: ['nutzungsdaten', 'beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'interest' },
{ id: 'website_betrieb', name: 'Website-Betrieb', purpose: 'Bereitstellung der Unternehmenswebsite und Kontaktformulare', primary_categories: ['nutzungsdaten', 'kontaktdaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'interest', hasServiceProvider: true, legalHint: 'Bei externem Website-Management: AVV nach Art. 28 DSGVO mit dem Dienstleister erforderlich. Cookies, Analytics und Kontaktformulare verarbeiten personenbezogene Daten \u2014 auch wenn der Dienstleister sie technisch betreibt, bleibt Ihr Unternehmen verantwortlich.' },
{ id: 'it_sicherheit', name: 'IT-Sicherheit / Logging', purpose: '\u00DCberwachung der IT-Sicherheit, Log-Analyse, Vorfallbehandlung', primary_categories: ['nutzungsdaten', 'beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'interest' },
],
},
{
id: 'recht', name: 'Recht / Compliance', icon: '\u2696\uFE0F',
activities: [
{ id: 'datenschutzanfragen', name: 'Betroffenenrechte (DSGVO)', purpose: 'Bearbeitung von Auskunfts-, L\u00F6sch- und Berichtigungsanfragen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'legal' },
{ id: 'auftragsverarbeitung', name: 'Auftragsverarbeitung (AVV)', purpose: 'Dokumentation und Verwaltung von Auftragsverarbeitungsverh\u00E4ltnissen', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'legal' },
{ id: 'whistleblowing', name: 'Hinweisgebersystem', purpose: 'Entgegennahme und Bearbeitung von Meldungen nach HinSchG', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'legal', categoryInfo: { stammdaten: 'Identit\u00E4t des Hinweisgebers (besonders sch\u00FCtzenswert! \u00A7 8 HinSchG Vertraulichkeitsgebot)', kontaktdaten: 'Kontaktdaten nur f\u00FCr zust\u00E4ndige Meldestelle zug\u00E4nglich', kommunikation: 'Meldungsinhalt, Kommunikationsverlauf, Zeugenaussagen. L\u00F6schfrist: 3 Jahre nach Abschluss (\u00A7 11 Abs. 5 HinSchG)' } },
],
},
]
// ── Abteilungen die je nach Kontext relevant sind ──
const OPTIONAL_DEPARTMENTS: ActivityDepartment[] = [
{
id: 'einkauf', name: 'Einkauf / Beschaffung', icon: '\uD83D\uDED2',
activities: [
{ id: 'lieferantenverwaltung', name: 'Lieferantenverwaltung', purpose: 'Erfassung und Pflege von Lieferantenstammdaten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'bestellwesen', name: 'Bestellwesen', purpose: 'Abwicklung von Bestellungen bei Lieferanten', primary_categories: ['stammdaten', 'vertragsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'lieferantenbewertung', name: 'Lieferantenbewertung', purpose: 'Bewertung und Qualifizierung von Lieferanten', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'interest' },
],
},
{
id: 'produktion', name: 'Produktion / Fertigung', icon: '\uD83C\uDFED',
activities: [
{ id: 'produktionsplanung', name: 'Produktionsplanung', purpose: 'Planung und Steuerung von Produktionsprozessen inkl. Personalzuordnung', primary_categories: ['beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'qualitaetskontrolle', name: 'Qualit\u00E4tskontrolle', purpose: 'Pr\u00FCfung und Dokumentation der Produktqualit\u00E4t', primary_categories: ['beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'arbeitssicherheit', name: 'Arbeitssicherheit / Arbeitsschutz', purpose: 'Dokumentation von Arbeitsschutzma\u00DFnahmen, Unf\u00E4llen, Gef\u00E4hrdungsbeurteilungen', primary_categories: ['beschaeftigtendaten'], art9_relevant: ['gesundheit'], default_legal_basis: 'legal' },
{ id: 'schichtplanung', name: 'Schichtplanung', purpose: 'Erstellung und Verwaltung von Schichtpl\u00E4nen', primary_categories: ['beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'contract' },
],
},
{
id: 'logistik', name: 'Logistik / Versand', icon: '\uD83D\uDE9A',
activities: [
{ id: 'versandabwicklung', name: 'Versandabwicklung', purpose: 'Verarbeitung von Empf\u00E4nger- und Versanddaten f\u00FCr den Warenversand', primary_categories: ['stammdaten', 'kontaktdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'lieferverfolgung', name: 'Lieferverfolgung / Sendungstracking', purpose: 'Nachverfolgung von Sendungen und Zustellung', primary_categories: ['stammdaten', 'kontaktdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'lagerverwaltung', name: 'Lagerverwaltung', purpose: 'Verwaltung von Lagerbest\u00E4nden und Warenbewegungen', primary_categories: ['stammdaten', 'beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'retouren', name: 'Retourenmanagement', purpose: 'Bearbeitung von Warenr\u00FCcksendungen', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
],
},
{
id: 'kundenservice', name: 'Kundenservice / Support', icon: '\uD83C\uDFA7',
activities: [
{ id: 'ticketsystem', name: 'Ticketsystem / Support', purpose: 'Erfassung und Bearbeitung von Kundenanfragen und Supportf\u00E4llen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'beschwerdemanagement', name: 'Beschwerdemanagement', purpose: 'Bearbeitung und Dokumentation von Kundenbeschwerden', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'contract' },
],
},
{
id: 'facility', name: 'Facility Management', icon: '\uD83C\uDFE2',
activities: [
{ id: 'zutrittskontrolle', name: 'Zutrittskontrolle', purpose: 'Kontrolle und Protokollierung des Zutritts zu Geb\u00E4uden und R\u00E4umen', primary_categories: ['beschaeftigtendaten', 'stammdaten', 'bilddaten'], art9_relevant: ['biometrie'], default_legal_basis: 'interest' },
{ id: 'videoueberwachung', name: 'Video\u00FCberwachung', purpose: '\u00DCberwachung von Geb\u00E4uden und Gel\u00E4nden mittels Videokameras', primary_categories: ['bilddaten', 'beschaeftigtendaten'], art9_relevant: ['biometrie'], default_legal_basis: 'interest', categoryInfo: { bilddaten: 'Videoaufzeichnungen von Kameras. Speicherdauer: empfohlen max. 72h (BeschDG-Entwurf). Datenschutzhinweis-Schilder (Art. 13 DSGVO) sind Pflicht. Betriebsrat hat Mitbestimmungsrecht (\u00A7 87 Abs. 1 Nr. 6 BetrVG)' } },
{ id: 'besuchermanagement', name: 'Besuchermanagement', purpose: 'Erfassung und Verwaltung von Besucherdaten', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'interest' },
],
},
]
// ── Branchenspezifische Abteilungen ──
const INDUSTRY_DEPARTMENTS: Record<string, ActivityDepartment[]> = {
'E-Commerce / Handel': [{
id: 'ecommerce', name: 'E-Commerce / Webshop', icon: '\uD83D\uDECD\uFE0F',
activities: [
{ id: 'bestellabwicklung', name: 'Bestellabwicklung (Webshop)', purpose: 'Verarbeitung von Kundenbestellungen im Online-Shop', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'kundenkonto', name: 'Kundenkonto-Verwaltung', purpose: 'Verwaltung registrierter Kundenkonten im Online-Shop', primary_categories: ['stammdaten', 'kontaktdaten', 'nutzungsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'webshop_analyse', name: 'Webshop-Analyse / Conversion', purpose: 'Analyse des Kaufverhaltens und Conversion-Rates', primary_categories: ['nutzungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'consent' },
{ id: 'produktbewertungen', name: 'Produktbewertungen / Reviews', purpose: 'Verwaltung von Kundenrezensionen und Produktbewertungen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'consent' },
],
}],
'Gesundheitswesen': [{
id: 'gesundheit_dept', name: 'Medizin / Patientenversorgung', icon: '\uD83C\uDFE5',
activities: [
{ id: 'patientenverwaltung', name: 'Patientenverwaltung', purpose: 'Verwaltung von Patientenstammdaten und Krankengeschichte', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten'], art9_relevant: ['gesundheit', 'genetik'], default_legal_basis: 'contract' },
{ id: 'terminplanung_med', name: 'Terminplanung (Patienten)', purpose: 'Vergabe und Verwaltung von Patiententerminen', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: ['gesundheit'], default_legal_basis: 'contract' },
{ id: 'kv_abrechnung', name: 'KV-Abrechnung', purpose: 'Abrechnung von Leistungen gegen\u00FCber Kassen\u00E4rztlichen Vereinigungen', primary_categories: ['stammdaten', 'zahlungsdaten'], art9_relevant: ['gesundheit'], default_legal_basis: 'legal' },
{ id: 'med_dokumentation', name: 'Medizinische Dokumentation', purpose: 'Dokumentation von Diagnosen, Therapien und Behandlungsverl\u00E4ufen', primary_categories: ['stammdaten'], art9_relevant: ['gesundheit', 'genetik'], default_legal_basis: 'legal' },
],
}],
'Finanzdienstleistungen': [{
id: 'finanz_dept', name: 'Regulatorik / Finanzaufsicht', icon: '\uD83C\uDFE6',
activities: [
{ id: 'kyc', name: 'Know Your Customer (KYC)', purpose: 'Identifizierung und Verifizierung von Kunden gem\u00E4\u00DF GwG', primary_categories: ['stammdaten', 'kontaktdaten', 'bilddaten'], art9_relevant: [], default_legal_basis: 'legal' },
{ id: 'kontoverwaltung', name: 'Kontoverwaltung', purpose: 'Verwaltung von Kundenkonten und Kontobewegungen', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'geldwaeschepraevention', name: 'Geldw\u00E4schepr\u00E4vention (AML)', purpose: '\u00DCberwachung verd\u00E4chtiger Transaktionen nach GwG', primary_categories: ['stammdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'legal' },
],
}],
'Bildung': [{
id: 'bildung_dept', name: 'Bildung / Lehre', icon: '\uD83C\uDF93',
activities: [
{ id: 'schuelerverwaltung', name: 'Sch\u00FCler-/Teilnehmerverwaltung', purpose: 'Verwaltung von Lernenden, Noten, Anwesenheit', primary_categories: ['stammdaten', 'kontaktdaten', 'nutzungsdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'contract' },
{ id: 'lernplattform', name: 'Lernplattform / LMS', purpose: 'Bereitstellung und Nutzung digitaler Lernplattformen', primary_categories: ['stammdaten', 'nutzungsdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'pruefungsverwaltung', name: 'Pr\u00FCfungsverwaltung', purpose: 'Verwaltung und Dokumentation von Pr\u00FCfungen und Noten', primary_categories: ['stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
],
}],
'Immobilien': [{
id: 'immobilien_dept', name: 'Immobilienverwaltung', icon: '\uD83C\uDFE0',
activities: [
{ id: 'mieterverwaltung', name: 'Mieterverwaltung', purpose: 'Verwaltung von Mietvertr\u00E4gen und Mieterdaten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'nebenkostenabrechnung', name: 'Nebenkostenabrechnung', purpose: 'Erstellung und Versand von Nebenkostenabrechnungen', primary_categories: ['stammdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
],
}],
}
// Compute which departments to show based on company context
export function getRelevantDepartments(industry: string | string[], businessModel: string | undefined, companySize: string | undefined): ActivityDepartment[] {
const departments: ActivityDepartment[] = [...UNIVERSAL_DEPARTMENTS]
// Always show optional departments
departments.push(...OPTIONAL_DEPARTMENTS)
// Add industry-specific departments (support multi-select)
const industries = Array.isArray(industry) ? industry : [industry]
const addedIds = new Set<string>()
for (const ind of industries) {
const industryDepts = INDUSTRY_DEPARTMENTS[ind]
if (industryDepts) {
for (const dept of industryDepts) {
if (!addedIds.has(dept.id)) {
addedIds.add(dept.id)
departments.push(dept)
}
}
}
}
return departments
}
// Helper: find template for an activity ID across all departments
export function findTemplate(departments: ActivityDepartment[], activityId: string) {
for (const dept of departments) {
const t = dept.activities.find(a => a.id === activityId)
if (t) return t
}
return null
}

View File

@@ -0,0 +1,65 @@
import { AISystemTemplate } from './types'
export const AI_SYSTEM_TEMPLATES: { category: string; icon: string; systems: AISystemTemplate[] }[] = [
{
category: 'Text-KI / Chatbots',
icon: '\uD83D\uDCAC',
systems: [
{ id: 'chatgpt', name: 'ChatGPT', vendor: 'OpenAI', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Kundensupport', 'Zusammenfassungen', 'Recherche'], dataWarning: 'Datenverarbeitung in den USA. Eingaben koennen fuer Training verwendet werden (opt-out moeglich).', processes_personal_data_likely: true },
{ id: 'claude', name: 'Claude', vendor: 'Anthropic', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Analyse', 'Zusammenfassungen', 'Code-Review'], dataWarning: 'Datenverarbeitung in den USA. Eingaben werden NICHT fuer Training verwendet.', processes_personal_data_likely: true },
{ id: 'gemini', name: 'Google Gemini', vendor: 'Google', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Recherche', 'Zusammenfassungen'], dataWarning: 'Datenverarbeitung in den USA/EU je nach Einstellung.', processes_personal_data_likely: true },
{ id: 'perplexity', name: 'Perplexity', vendor: 'Perplexity AI', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Websuche mit KI', 'Recherche', 'Zusammenfassungen'], dataWarning: 'Websuche + KI. Eingaben werden verarbeitet.', processes_personal_data_likely: false },
],
},
{
category: 'Office / Produktivitaet',
icon: '\uD83D\uDCCE',
systems: [
{ id: 'copilot365', name: 'Microsoft 365 Copilot', vendor: 'Microsoft', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['E-Mail-Entwuerfe', 'Dokument-Zusammenfassung', 'Praesentationen', 'Excel-Analysen'], dataWarning: 'In M365-Tenant integriert. Daten bleiben im Tenant, aber: KI-Verarbeitung ggf. in den USA.', processes_personal_data_likely: true },
{ id: 'google-workspace-ai', name: 'Google Workspace AI', vendor: 'Google', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['E-Mail-Entwuerfe', 'Dokument-Zusammenfassung', 'Tabellen-Analysen'], dataWarning: 'Duet AI in Docs, Sheets, Gmail. Datenverarbeitung je nach Workspace-Region.', processes_personal_data_likely: true },
{ id: 'notion-ai', name: 'Notion AI', vendor: 'Notion', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['Texterstellung', 'Zusammenfassungen', 'Aufgabenverwaltung'], dataWarning: 'Datenverarbeitung in den USA.', processes_personal_data_likely: false },
{ id: 'grammarly', name: 'Grammarly', vendor: 'Grammarly', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['Textkorrektur', 'Stiloptimierung', 'Tonalitaet'], dataWarning: 'Textanalyse, Datenverarbeitung in den USA.', processes_personal_data_likely: false },
],
},
{
category: 'Code-Assistenz',
icon: '\uD83D\uDCBB',
systems: [
{ id: 'github-copilot', name: 'GitHub Copilot', vendor: 'Microsoft/GitHub', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Vorschlaege', 'Code-Generierung', 'Dokumentation'], dataWarning: 'Code-Vorschlaege basierend auf Kontext. Code-Snippets werden verarbeitet.', processes_personal_data_likely: false },
{ id: 'cursor', name: 'Cursor / Windsurf', vendor: 'Cursor Inc.', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Generierung', 'Refactoring', 'Debugging'], dataWarning: 'KI-Code-Editor. Code wird an KI-Backend uebermittelt.', processes_personal_data_likely: false },
{ id: 'codewhisperer', name: 'Amazon CodeWhisperer', vendor: 'AWS', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Vorschlaege', 'Sicherheits-Scans'], dataWarning: 'Code-Vorschlaege. Opt-out fuer Code-Sharing moeglich.', processes_personal_data_likely: false },
],
},
{
category: 'Bildgenerierung',
icon: '\uD83C\uDFA8',
systems: [
{ id: 'dalle', name: 'DALL-E / ChatGPT Bildgenerierung', vendor: 'OpenAI', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Marketing-Material', 'Illustrationen'], dataWarning: 'Bildgenerierung. Prompts werden verarbeitet.', processes_personal_data_likely: false },
{ id: 'midjourney', name: 'Midjourney', vendor: 'Midjourney Inc.', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Design-Konzepte', 'Illustrationen'], dataWarning: 'Bildgenerierung via Discord. Prompts sind oeffentlich sichtbar (ausser Pro-Plan).', processes_personal_data_likely: false },
{ id: 'firefly', name: 'Adobe Firefly', vendor: 'Adobe', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Bildbearbeitung', 'Design'], dataWarning: 'In Creative Cloud integriert. Trainiert auf lizenzierten Inhalten.', processes_personal_data_likely: false },
],
},
{
category: 'Uebersetzung / Sprache',
icon: '\uD83C\uDF10',
systems: [
{ id: 'deepl', name: 'DeepL', vendor: 'DeepL SE', category: 'Uebersetzung / Sprache', icon: '\uD83C\uDF10', typicalPurposes: ['Uebersetzung', 'Dokumentenuebersetzung'], dataWarning: 'Deutscher Anbieter, Server in EU. DeepL Pro: Texte werden NICHT gespeichert.', processes_personal_data_likely: false },
{ id: 'deepl-write', name: 'DeepL Write', vendor: 'DeepL SE', category: 'Uebersetzung / Sprache', icon: '\uD83C\uDF10', typicalPurposes: ['Textoptimierung', 'Stilverbesserung'], dataWarning: 'Deutscher Anbieter, Server in EU. Gleiche Datenschutz-Bedingungen wie DeepL.', processes_personal_data_likely: false },
],
},
{
category: 'CRM / Sales KI',
icon: '\uD83D\uDCCA',
systems: [
{ id: 'salesforce-einstein', name: 'Salesforce Einstein', vendor: 'Salesforce', category: 'CRM / Sales KI', icon: '\uD83D\uDCCA', typicalPurposes: ['Lead-Scoring', 'Prognosen', 'Empfehlungen'], dataWarning: 'In Salesforce integriert. Verarbeitet CRM-Daten.', processes_personal_data_likely: true },
{ id: 'hubspot-ai', name: 'HubSpot AI', vendor: 'HubSpot', category: 'CRM / Sales KI', icon: '\uD83D\uDCCA', typicalPurposes: ['E-Mail-Generierung', 'Lead-Scoring', 'Content-Erstellung'], dataWarning: 'KI-Features in HubSpot CRM. Datenverarbeitung in USA/EU.', processes_personal_data_likely: true },
],
},
{
category: 'Interne / Eigene Systeme',
icon: '\uD83C\uDFE2',
systems: [
{ id: 'internal-ai', name: 'Eigenes KI-System', vendor: 'Intern', category: 'Interne / Eigene Systeme', icon: '\uD83C\uDFE2', typicalPurposes: ['Interne Analyse', 'Automatisierung', 'Prozessoptimierung'], dataWarning: undefined, processes_personal_data_likely: false },
],
},
]

View File

@@ -0,0 +1,139 @@
import { LegalForm } from '@/lib/sdk/types'
import { OfferingType } from '@/lib/sdk/types'
// =============================================================================
// WIZARD STEPS
// =============================================================================
export const BASE_WIZARD_STEPS = [
{ id: 1, name: 'Basisinfos', description: 'Firmenname und Rechtsform' },
{ id: 2, name: 'Geschaeftsmodell', description: 'B2B, B2C und Angebote' },
{ id: 3, name: 'Firmengroesse', description: 'Mitarbeiter und Umsatz' },
{ id: 4, name: 'Standorte', description: 'Hauptsitz und Zielmaerkte' },
{ id: 5, name: 'Datenschutz', description: 'Rollen und DSB' },
{ id: 6, name: 'Zertifizierungen & Kontakte', description: 'Bestehende und angestrebte Zertifizierungen' },
]
export const MACHINE_BUILDER_STEP = { id: 7, name: 'Produkt & Maschine', description: 'Software, KI und CE in Ihrem Produkt' }
// =============================================================================
// INDUSTRIES
// =============================================================================
export const INDUSTRIES = [
'Technologie / IT',
'IT Dienstleistungen',
'E-Commerce / Handel',
'Finanzdienstleistungen',
'Versicherungen',
'Gesundheitswesen',
'Pharma',
'Bildung',
'Beratung / Consulting',
'Marketing / Agentur',
'Produktion / Industrie',
'Logistik / Transport',
'Immobilien',
'Bau',
'Energie',
'Automobil',
'Luft- und Raumfahrt',
'Maschinenbau',
'Anlagenbau',
'Automatisierung',
'Robotik',
'Messtechnik',
'Agrar',
'Chemie',
'Minen / Bergbau',
'Telekommunikation',
'Medien / Verlage',
'Gastronomie / Hotellerie',
'Recht / Kanzlei',
'Oeffentlicher Dienst',
'Sonstige',
]
const MACHINE_BUILDER_INDUSTRIES = [
'Maschinenbau',
'Anlagenbau',
'Automatisierung',
'Robotik',
'Messtechnik',
]
export const isMachineBuilderIndustry = (industry: string | string[]) => {
const industries = Array.isArray(industry) ? industry : [industry]
return industries.some(i => MACHINE_BUILDER_INDUSTRIES.includes(i))
}
export function getWizardSteps(industry: string | string[]) {
if (isMachineBuilderIndustry(industry)) {
return [...BASE_WIZARD_STEPS, MACHINE_BUILDER_STEP]
}
return BASE_WIZARD_STEPS
}
// =============================================================================
// LEGAL FORMS
// =============================================================================
export const LEGAL_FORM_LABELS: Record<LegalForm, string> = {
einzelunternehmen: 'Einzelunternehmen',
gbr: 'GbR',
ohg: 'OHG',
kg: 'KG',
gmbh: 'GmbH',
ug: 'UG (haftungsbeschränkt)',
ag: 'AG',
gmbh_co_kg: 'GmbH & Co. KG',
ev: 'e.V. (Verein)',
stiftung: 'Stiftung',
other: 'Sonstige',
}
// =============================================================================
// STEP EXPLANATIONS
// =============================================================================
export const STEP_EXPLANATIONS: Record<number, string> = {
1: 'Rechtsform und Gründungsjahr bestimmen, welche Meldepflichten und Schwellenwerte für Ihr Unternehmen gelten (z.B. NIS2, AI Act).',
2: 'Ihr Geschäftsmodell und Ihre Angebote bestimmen, welche DSGVO-Pflichten greifen: B2C erfordert z.B. strengere Einwilligungsregeln, Webshops brauchen Cookie-Banner und Datenschutzerklärungen, SaaS-Angebote eine Auftragsverarbeitung.',
3: 'Die Unternehmensgröße bestimmt, ob Sie einen DSB benennen müssen (ab 20 MA), ob NIS2-Pflichten greifen und welche Audit-Anforderungen gelten.',
4: 'Standorte und Zielmärkte bestimmen, welche nationalen Datenschutzgesetze zusätzlich zur DSGVO greifen (z.B. BDSG, DSG-AT, UK GDPR, CCPA).',
5: 'Ob Sie Verantwortlicher oder Auftragsverarbeiter sind, bestimmt Ihre DSGVO-Pflichten grundlegend.',
6: 'Regulierungsrahmen und Prüfzyklen definieren, welche Compliance-Module für Sie aktiviert werden und in welchem Rhythmus Audits stattfinden.',
7: 'Als Maschinenbauer gelten zusätzliche Anforderungen: CE-Kennzeichnung, Maschinenverordnung, Produktsicherheit und ggf. Hochrisiko-KI im Sinne des AI Act.',
}
// =============================================================================
// OFFERING URL CONFIG
// =============================================================================
export const OFFERING_URL_CONFIG: Partial<Record<OfferingType, { label: string; placeholder: string; hint: string }>> = {
website: { label: 'Website-Domain', placeholder: 'https://www.beispiel.de', hint: 'Ihre Unternehmenswebsite' },
webshop: { label: 'Online-Shop URL', placeholder: 'https://shop.beispiel.de', hint: 'URL zu Ihrem Online-Shop' },
app_mobile: { label: 'App-Store Links', placeholder: 'https://apps.apple.com/... oder https://play.google.com/...', hint: 'Apple App Store und/oder Google Play Store Link' },
software_saas: { label: 'SaaS-Portal URL', placeholder: 'https://app.beispiel.de', hint: 'Login-/Registrierungsseite Ihres Kundenportals' },
app_web: { label: 'Web-App URL', placeholder: 'https://app.beispiel.de', hint: 'URL zu Ihrer Web-Anwendung' },
}
// =============================================================================
// CERTIFICATIONS
// =============================================================================
export const CERTIFICATIONS = [
{ id: 'iso27001', label: 'ISO 27001', desc: 'Informationssicherheits-Managementsystem' },
{ id: 'iso27701', label: 'ISO 27701', desc: 'Datenschutz-Managementsystem' },
{ id: 'iso9001', label: 'ISO 9001', desc: 'Qualitaetsmanagement' },
{ id: 'iso14001', label: 'ISO 14001', desc: 'Umweltmanagement' },
{ id: 'iso22301', label: 'ISO 22301', desc: 'Business Continuity Management' },
{ id: 'iso42001', label: 'ISO 42001', desc: 'KI-Managementsystem' },
{ id: 'tisax', label: 'TISAX', desc: 'Trusted Information Security Assessment Exchange (Automotive)' },
{ id: 'soc2', label: 'SOC 2', desc: 'Service Organization Controls (Typ I/II)' },
{ id: 'c5', label: 'C5', desc: 'Cloud Computing Compliance Criteria Catalogue (BSI)' },
{ id: 'bsi_grundschutz', label: 'BSI IT-Grundschutz', desc: 'IT-Grundschutz-Zertifikat oder Testat' },
{ id: 'pci_dss', label: 'PCI DSS', desc: 'Payment Card Industry Data Security Standard' },
{ id: 'hipaa', label: 'HIPAA', desc: 'Health Insurance Portability and Accountability Act' },
{ id: 'other', label: 'Sonstige', desc: 'Andere Zertifizierungen' },
]

View File

@@ -0,0 +1,59 @@
export interface ProcessingActivity {
id: string
name: string
purpose: string
data_categories: string[]
legal_basis: string
department?: string
custom?: boolean
usesServiceProvider?: boolean
serviceProviderName?: string
}
export interface AISystem {
id: string
name: string
vendor: string
purpose: string
purposes?: string[]
processes_personal_data: boolean
isCustom?: boolean
notes?: string
}
export interface CertificationEntry {
certId: string
certifier?: string
lastDate?: string
customName?: string
}
export interface ActivityTemplate {
id: string
name: string
purpose: string
primary_categories: string[]
art9_relevant: string[]
default_legal_basis: string
legalHint?: string
hasServiceProvider?: boolean
categoryInfo?: Record<string, string>
}
export interface ActivityDepartment {
id: string
name: string
icon: string
activities: ActivityTemplate[]
}
export interface AISystemTemplate {
id: string
name: string
vendor: string
category: string
icon: string
typicalPurposes: string[]
dataWarning?: string
processes_personal_data_likely: boolean
}

View File

@@ -0,0 +1,305 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import React from 'react'
import { CompanyProfile } from '@/lib/sdk/types'
import { useSDK } from '@/lib/sdk'
import { isMachineBuilderIndustry, getWizardSteps } from '../_components/constants'
const INITIAL_FORM_DATA: Partial<CompanyProfile> = {
companyName: '', legalForm: undefined, industry: [], industryOther: '', foundedYear: null,
businessModel: undefined, offerings: [], offeringUrls: {},
companySize: undefined, employeeCount: '', annualRevenue: '',
headquartersCountry: 'DE', headquartersCountryOther: '', headquartersStreet: '',
headquartersZip: '', headquartersCity: '', headquartersState: '',
hasInternationalLocations: false, internationalCountries: [], targetMarkets: [],
primaryJurisdiction: 'DE', isDataController: true, isDataProcessor: false,
dpoName: null, dpoEmail: null, legalContactName: null, legalContactEmail: null,
isComplete: false, completedAt: null,
}
function buildProfilePayload(formData: Partial<CompanyProfile>, projectId: string | null, isComplete: boolean) {
return {
project_id: projectId || null,
company_name: formData.companyName || '',
legal_form: formData.legalForm || 'GmbH',
industry: formData.industry || [],
industry_other: formData.industryOther || '',
founded_year: formData.foundedYear || null,
business_model: formData.businessModel || 'B2B',
offerings: formData.offerings || [],
offering_urls: formData.offeringUrls || {},
company_size: formData.companySize || 'small',
employee_count: formData.employeeCount || '',
annual_revenue: formData.annualRevenue || '',
headquarters_country: formData.headquartersCountry || 'DE',
headquarters_country_other: formData.headquartersCountryOther || '',
headquarters_street: formData.headquartersStreet || '',
headquarters_zip: formData.headquartersZip || '',
headquarters_city: formData.headquartersCity || '',
headquarters_state: formData.headquartersState || '',
has_international_locations: formData.hasInternationalLocations || false,
international_countries: formData.internationalCountries || [],
target_markets: formData.targetMarkets || [],
primary_jurisdiction: formData.primaryJurisdiction || 'DE',
is_data_controller: formData.isDataController ?? true,
is_data_processor: formData.isDataProcessor ?? false,
dpo_name: formData.dpoName || '',
dpo_email: formData.dpoEmail || '',
is_complete: isComplete,
processing_systems: (formData as any).processingSystems || [],
ai_systems: (formData as any).aiSystems || [],
technical_contacts: (formData as any).technicalContacts || [],
existing_certifications: (formData as any).existingCertifications || [],
target_certifications: (formData as any).targetCertifications || [],
target_certification_other: (formData as any).targetCertificationOther || '',
review_cycle_months: (formData as any).reviewCycleMonths || 12,
repos: (formData as any).repos || [],
document_sources: (formData as any).documentSources || [],
...(formData.machineBuilder ? {
machine_builder: {
product_types: formData.machineBuilder.productTypes || [],
product_description: formData.machineBuilder.productDescription || '',
product_pride: formData.machineBuilder.productPride || '',
contains_software: formData.machineBuilder.containsSoftware || false,
contains_firmware: formData.machineBuilder.containsFirmware || false,
contains_ai: formData.machineBuilder.containsAI || false,
ai_integration_type: formData.machineBuilder.aiIntegrationType || [],
has_safety_function: formData.machineBuilder.hasSafetyFunction || false,
safety_function_description: formData.machineBuilder.safetyFunctionDescription || '',
autonomous_behavior: formData.machineBuilder.autonomousBehavior || false,
human_oversight_level: formData.machineBuilder.humanOversightLevel || 'full',
is_networked: formData.machineBuilder.isNetworked || false,
has_remote_access: formData.machineBuilder.hasRemoteAccess || false,
has_ota_updates: formData.machineBuilder.hasOTAUpdates || false,
update_mechanism: formData.machineBuilder.updateMechanism || '',
export_markets: formData.machineBuilder.exportMarkets || [],
critical_sector_clients: formData.machineBuilder.criticalSectorClients || false,
critical_sectors: formData.machineBuilder.criticalSectors || [],
oem_clients: formData.machineBuilder.oemClients || false,
ce_marking_required: formData.machineBuilder.ceMarkingRequired || false,
existing_ce_process: formData.machineBuilder.existingCEProcess || false,
has_risk_assessment: formData.machineBuilder.hasRiskAssessment || false,
},
} : {}),
}
}
export function useCompanyProfileForm() {
const { state, dispatch, setCompanyProfile, goToNextStep, projectId } = useSDK()
const [currentStep, setCurrentStep] = useState(1)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [formData, setFormData] = useState<Partial<CompanyProfile>>(INITIAL_FORM_DATA)
const [draftSaveStatus, setDraftSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const showMachineBuilderStep = isMachineBuilderIndustry(formData.industry || [])
const wizardSteps = getWizardSteps(formData.industry || [])
const lastStep = wizardSteps[wizardSteps.length - 1].id
const profileApiUrl = (extra?: string) => {
const params = new URLSearchParams()
if (projectId) params.set('project_id', projectId)
const qs = params.toString()
const base = '/api/sdk/v1/company-profile' + (extra || '')
return qs ? `${base}?${qs}` : base
}
// Load existing profile
useEffect(() => {
let cancelled = false
async function loadFromBackend() {
try {
const apiUrl = '/api/sdk/v1/company-profile' + (projectId ? `?project_id=${encodeURIComponent(projectId)}` : '')
const response = await fetch(apiUrl)
if (response.ok) {
const data = await response.json()
if (data && !cancelled) {
const backendProfile: Partial<CompanyProfile> = {
companyName: data.company_name || '', legalForm: data.legal_form || undefined,
industry: Array.isArray(data.industry) ? data.industry : (data.industry ? [data.industry] : []),
industryOther: data.industry_other || '', foundedYear: data.founded_year || undefined,
businessModel: data.business_model || undefined, offerings: data.offerings || [],
offeringUrls: data.offering_urls || {}, companySize: data.company_size || undefined,
employeeCount: data.employee_count || '', annualRevenue: data.annual_revenue || '',
headquartersCountry: data.headquarters_country || 'DE',
headquartersCountryOther: data.headquarters_country_other || '',
headquartersStreet: data.headquarters_street || '',
headquartersZip: data.headquarters_zip || '', headquartersCity: data.headquarters_city || '',
headquartersState: data.headquarters_state || '',
hasInternationalLocations: data.has_international_locations || false,
internationalCountries: data.international_countries || [],
targetMarkets: data.target_markets || [], primaryJurisdiction: data.primary_jurisdiction || 'DE',
isDataController: data.is_data_controller ?? true, isDataProcessor: data.is_data_processor ?? false,
dpoName: data.dpo_name || '', dpoEmail: data.dpo_email || '',
isComplete: data.is_complete || false,
processingSystems: data.processing_systems || [], aiSystems: data.ai_systems || [],
technicalContacts: data.technical_contacts || [],
existingCertifications: data.existing_certifications || [],
targetCertifications: data.target_certifications || [],
targetCertificationOther: data.target_certification_other || '',
reviewCycleMonths: data.review_cycle_months || 12,
repos: data.repos || [], documentSources: data.document_sources || [],
} as any
setFormData(backendProfile)
setCompanyProfile(backendProfile as CompanyProfile)
if (backendProfile.isComplete) {
setCurrentStep(99)
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
}
return
}
}
} catch { /* Backend not available, fall through to SDK state */ }
if (!cancelled && state.companyProfile) {
setFormData(state.companyProfile)
if (state.companyProfile.isComplete) setCurrentStep(99)
}
}
loadFromBackend()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId])
const updateFormData = (updates: Partial<CompanyProfile>) => {
setFormData(prev => ({ ...prev, ...updates }))
}
// Auto-save to SDK context (debounced)
const autoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const initialLoadDone = useRef(false)
useEffect(() => {
if (!initialLoadDone.current) {
if (formData.companyName !== undefined) initialLoadDone.current = true
return
}
if (currentStep === 99) return
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
autoSaveRef.current = setTimeout(() => {
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
if (hasData) setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
}, 500)
return () => { if (autoSaveRef.current) clearTimeout(autoSaveRef.current) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData, currentStep])
// Auto-save draft to backend (debounced, 2s)
const backendAutoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const draftSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (!initialLoadDone.current) return
if (currentStep === 99) return
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
if (!hasData) return
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
backendAutoSaveRef.current = setTimeout(async () => {
try {
await fetch(profileApiUrl(), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
})
setDraftSaveStatus('saved')
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 3000)
} catch { /* Silent fail for auto-save */ }
}, 2000)
return () => { if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData, currentStep])
const saveProfileDraft = async () => {
setDraftSaveStatus('saving')
try {
await fetch(profileApiUrl(), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
})
setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
setDraftSaveStatus('saved')
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 3000)
} catch (err) {
console.error('Draft save failed:', err)
setDraftSaveStatus('error')
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 5000)
}
}
const completeAndSaveProfile = async () => {
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
const completeProfile: CompanyProfile = { ...formData, isComplete: true, completedAt: new Date() } as CompanyProfile
try {
await fetch(profileApiUrl(), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildProfilePayload(formData, projectId, true)),
})
} catch (err) { console.error('Failed to save company profile to backend:', err) }
setCompanyProfile(completeProfile)
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
dispatch({ type: 'SET_STATE', payload: { projectVersion: (state.projectVersion || 0) + 1 } })
setCurrentStep(99)
}
const handleNext = () => {
if (currentStep < lastStep) {
const nextStep = currentStep + 1
if (nextStep === 7 && !showMachineBuilderStep) {
completeAndSaveProfile()
return
}
saveProfileDraft()
setCurrentStep(nextStep)
} else {
completeAndSaveProfile()
}
}
const handleBack = () => {
if (currentStep > 1) { saveProfileDraft(); setCurrentStep(prev => prev - 1) }
}
const handleDeleteProfile = async () => {
setIsDeleting(true)
try {
const response = await fetch(profileApiUrl(), { method: 'DELETE' })
if (response.ok) {
setFormData(INITIAL_FORM_DATA)
setCurrentStep(1)
dispatch({ type: 'SET_STATE', payload: { companyProfile: undefined } })
}
} catch (err) { console.error('Failed to delete company profile:', err) }
finally { setIsDeleting(false); setShowDeleteConfirm(false) }
}
const canProceed = () => {
switch (currentStep) {
case 1: return formData.companyName && formData.legalForm
case 2: return formData.businessModel && (formData.offerings?.length || 0) > 0
case 3: return formData.companySize
case 4: return formData.headquartersCountry && (formData.targetMarkets?.length || 0) > 0
case 5: return true
case 6: return true
case 7: return (formData.machineBuilder?.productDescription?.length || 0) > 0
default: return false
}
}
const isLastStep = currentStep === lastStep || (currentStep === 6 && !showMachineBuilderStep)
return {
formData, updateFormData, currentStep, setCurrentStep,
wizardSteps, showMachineBuilderStep, isLastStep,
draftSaveStatus, canProceed, handleNext, handleBack,
handleDeleteProfile, showDeleteConfirm, setShowDeleteConfirm,
isDeleting, goToNextStep,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
'use client'
import Link from 'next/link'
import { DashboardData, MappingsData, FindingsData } from '../_hooks/useComplianceHub'
export function MappingsAndFindings({
dashboard,
mappings,
findings,
}: {
dashboard: DashboardData | null
mappings: MappingsData | null
findings: FindingsData | null
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Control-Mappings</h3>
<Link href="/sdk/controls" className="text-sm text-purple-600 hover:text-purple-700">
Alle anzeigen
</Link>
</div>
<div className="flex items-center gap-6 mb-4">
<div>
<p className="text-4xl font-bold text-purple-600">{mappings?.total || 0}</p>
<p className="text-sm text-slate-500">Mappings gesamt</p>
</div>
<div className="flex-1 h-16 bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500 mb-1">Nach Verordnung</p>
<div className="flex gap-1 flex-wrap">
{mappings?.by_regulation && Object.entries(mappings.by_regulation).slice(0, 5).map(([reg, count]) => (
<span key={reg} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{reg}: {count}
</span>
))}
{!mappings?.by_regulation && (
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-xs">Keine Mappings vorhanden</span>
)}
</div>
</div>
</div>
<p className="text-sm text-slate-600">
Automatisch generierte Verknuepfungen zwischen {dashboard?.total_controls || 0} Controls
und {dashboard?.total_requirements || 0} Anforderungen aus {dashboard?.total_regulations || 0} Verordnungen.
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Audit Findings</h3>
<Link href="/sdk/audit-checklist" className="text-sm text-purple-600 hover:text-purple-700">
Audit Checkliste
</Link>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-sm font-medium text-red-800">Hauptabweichungen</span>
</div>
<p className="text-3xl font-bold text-red-600">{findings?.open_majors || 0}</p>
<p className="text-xs text-red-600">offen (blockiert Zertifizierung)</p>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
<span className="text-sm font-medium text-yellow-800">Nebenabweichungen</span>
</div>
<p className="text-3xl font-bold text-yellow-600">{findings?.open_minors || 0}</p>
<p className="text-xs text-yellow-600">offen (erfordert CAPA)</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">
Gesamt: {findings?.total || 0} Findings ({findings?.major_count || 0} Major, {findings?.minor_count || 0} Minor, {findings?.ofi_count || 0} OFI)
</span>
{(findings?.open_majors || 0) === 0 ? (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">
Zertifizierung moeglich
</span>
) : (
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
Zertifizierung blockiert
</span>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import type { ModuleStatusData } from './types'
import { MODULE_ICONS } from './types'
interface ModulesTabProps {
moduleStatus: ModuleStatusData | null
}
export function ModulesTab({ moduleStatus }: ModulesTabProps) {
if (!moduleStatus) {
return (
<div className="flex items-center justify-center h-48">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
}
return (
<>
{/* Summary */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-xl shadow-sm border p-6 text-center">
<p className="text-sm text-slate-500">Gesamt-Fortschritt</p>
<p className="text-3xl font-bold text-purple-600">{moduleStatus.overall_progress.toFixed(0)}%</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6 text-center">
<p className="text-sm text-slate-500">Module gestartet</p>
<p className="text-3xl font-bold text-blue-600">{moduleStatus.started}/{moduleStatus.total}</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6 text-center">
<p className="text-sm text-slate-500">Module abgeschlossen</p>
<p className="text-3xl font-bold text-green-600">{moduleStatus.complete}/{moduleStatus.total}</p>
</div>
</div>
{/* Module Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{moduleStatus.modules.map(mod => (
<div key={mod.key} className="bg-white rounded-xl shadow-sm border p-5">
<div className="flex items-center gap-3 mb-3">
<span className="text-2xl">{MODULE_ICONS[mod.key] || '📦'}</span>
<div>
<h4 className="font-medium text-slate-900">{mod.label}</h4>
<p className="text-xs text-slate-500">{mod.count} Eintraege</p>
</div>
<span className={`ml-auto px-2 py-0.5 text-xs rounded-full font-medium ${
mod.status === 'complete' ? 'bg-green-100 text-green-700' :
mod.status === 'in_progress' ? 'bg-yellow-100 text-yellow-700' :
'bg-slate-100 text-slate-500'
}`}>
{mod.status === 'complete' ? 'Fertig' :
mod.status === 'in_progress' ? 'In Arbeit' : 'Offen'}
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
mod.status === 'complete' ? 'bg-green-500' :
mod.status === 'in_progress' ? 'bg-yellow-500' : 'bg-slate-300'
}`}
style={{ width: `${mod.progress}%` }}
/>
</div>
</div>
))}
</div>
</>
)
}

View File

@@ -0,0 +1,388 @@
'use client'
import Link from 'next/link'
import type { DashboardData, MappingsData, FindingsData, NextAction } from './types'
import { DOMAIN_LABELS } from './types'
interface OverviewTabProps {
dashboard: DashboardData | null
mappings: MappingsData | null
findings: FindingsData | null
nextActions: NextAction[]
evidenceDistribution: {
by_confidence: Record<string, number>
four_eyes_pending: number
total: number
} | null
score: number
scoreColor: string
scoreBgColor: string
loadData: () => void
regulations: Array<{ id: string; code: string; name: string; regulation_type: string; requirement_count: number }>
}
export function OverviewTab({
dashboard, mappings, findings, nextActions, evidenceDistribution,
score, scoreColor, scoreBgColor, loadData, regulations,
}: OverviewTabProps) {
return (
<>
{/* Quick Actions */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
{[
{ href: '/sdk/audit-checklist', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01', label: 'Audit Checkliste', sub: `${dashboard?.total_requirements || '...'} Anforderungen`, color: 'purple' },
{ href: '/sdk/controls', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', label: 'Controls', sub: `${dashboard?.total_controls || '...'} Massnahmen`, color: 'green' },
{ href: '/sdk/evidence', icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z', label: 'Evidence', sub: 'Nachweise', color: 'blue' },
{ href: '/sdk/risks', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', label: 'Risk Matrix', sub: '5x5 Risiken', color: 'red' },
{ href: '/sdk/process-tasks', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4', label: 'Prozesse', sub: 'Aufgaben', color: 'indigo' },
{ href: '/sdk/audit-report', icon: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', label: 'Audit Report', sub: 'PDF Export', color: 'orange' },
].map(item => (
<Link
key={item.href}
href={item.href}
className={`p-4 rounded-lg border border-slate-200 hover:border-${item.color}-500 hover:bg-${item.color}-50 transition-colors text-center`}
>
<div className={`text-${item.color}-600 mb-2 flex justify-center`}>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={item.icon} />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">{item.label}</p>
<p className="text-xs text-slate-500 mt-1">{item.sub}</p>
</Link>
))}
</div>
</div>
{/* Score and Stats Row */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
<div className={`text-5xl font-bold ${scoreColor}`}>
{score.toFixed(0)}%
</div>
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
<div className={`h-full transition-all duration-500 ${scoreBgColor}`} style={{ width: `${score}%` }} />
</div>
<p className="mt-2 text-sm text-slate-500">
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
</p>
</div>
{[
{ label: 'Verordnungen', value: dashboard?.total_regulations || 0, sub: `${dashboard?.total_requirements || 0} Anforderungen`, iconColor: 'blue', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ label: 'Controls', value: dashboard?.total_controls || 0, sub: `${dashboard?.controls_by_status?.pass || 0} bestanden`, iconColor: 'green', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ label: 'Nachweise', value: dashboard?.total_evidence || 0, sub: `${dashboard?.evidence_by_status?.valid || 0} aktiv`, iconColor: 'purple', icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' },
{ label: 'Risiken', value: dashboard?.total_risks || 0, sub: `${(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch`, iconColor: 'red', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
].map(stat => (
<div key={stat.label} className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{stat.label}</p>
<p className="text-2xl font-bold text-slate-900">{stat.value}</p>
</div>
<div className={`w-10 h-10 bg-${stat.iconColor}-100 rounded-lg flex items-center justify-center`}>
<svg className={`w-5 h-5 text-${stat.iconColor}-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={stat.icon} />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{stat.sub}</p>
</div>
))}
</div>
{/* Anti-Fake-Evidence Section (Phase 3) */}
{dashboard && (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Anti-Fake-Evidence Status</h3>
{/* Confidence Distribution Bar */}
{evidenceDistribution && evidenceDistribution.total > 0 && (
<div className="mb-6">
<p className="text-sm text-slate-500 mb-2">Confidence-Verteilung ({evidenceDistribution.total} Nachweise)</p>
<div className="flex h-6 rounded-full overflow-hidden">
{(['E0', 'E1', 'E2', 'E3', 'E4'] as const).map(level => {
const count = evidenceDistribution.by_confidence[level] || 0
const pct = (count / evidenceDistribution.total) * 100
if (pct === 0) return null
const colors: Record<string, string> = {
E0: 'bg-red-400', E1: 'bg-yellow-400', E2: 'bg-blue-400', E3: 'bg-green-400', E4: 'bg-emerald-400'
}
return (
<div key={level} className={`${colors[level]} flex items-center justify-center text-xs text-white font-medium`}
style={{ width: `${pct}%` }} title={`${level}: ${count}`}>
{pct >= 10 ? `${level} (${count})` : ''}
</div>
)
})}
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
{(['E0', 'E1', 'E2', 'E3', 'E4'] as const).map(level => {
const count = evidenceDistribution.by_confidence[level] || 0
const dotColors: Record<string, string> = {
E0: 'bg-red-400', E1: 'bg-yellow-400', E2: 'bg-blue-400', E3: 'bg-green-400', E4: 'bg-emerald-400'
}
return (
<span key={level} className="flex items-center gap-1">
<span className={`w-2 h-2 rounded-full ${dotColors[level]}`} />
{level}: {count}
</span>
)
})}
</div>
</div>
)}
{/* Multi-Score Dimensions */}
{dashboard.multi_score && (
<div className="mb-6">
<p className="text-sm text-slate-500 mb-2">Multi-dimensionaler Score</p>
<div className="space-y-2">
{([
{ key: 'requirement_coverage', label: 'Anforderungsabdeckung', color: 'bg-blue-500' },
{ key: 'evidence_strength', label: 'Evidence-Staerke', color: 'bg-green-500' },
{ key: 'validation_quality', label: 'Validierungsqualitaet', color: 'bg-purple-500' },
{ key: 'evidence_freshness', label: 'Aktualitaet', color: 'bg-yellow-500' },
{ key: 'control_effectiveness', label: 'Control-Wirksamkeit', color: 'bg-indigo-500' },
] as const).map(dim => {
const value = (dashboard.multi_score as Record<string, number>)[dim.key] || 0
return (
<div key={dim.key} className="flex items-center gap-3">
<span className="text-xs text-slate-600 w-44 truncate">{dim.label}</span>
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div className={`h-full ${dim.color} rounded-full transition-all`} style={{ width: `${value}%` }} />
</div>
<span className="text-xs text-slate-600 w-12 text-right">{typeof value === 'number' ? value.toFixed(0) : value}%</span>
</div>
)
})}
<div className="flex items-center gap-3 pt-2 border-t border-slate-100">
<span className="text-xs font-semibold text-slate-700 w-44">Audit-Readiness</span>
<div className="flex-1 h-3 bg-slate-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${
(dashboard.multi_score.overall_readiness || 0) >= 80 ? 'bg-green-500' :
(dashboard.multi_score.overall_readiness || 0) >= 60 ? 'bg-yellow-500' : 'bg-red-500'
}`} style={{ width: `${dashboard.multi_score.overall_readiness || 0}%` }} />
</div>
<span className="text-xs font-semibold text-slate-700 w-12 text-right">
{typeof dashboard.multi_score.overall_readiness === 'number' ? dashboard.multi_score.overall_readiness.toFixed(0) : 0}%
</span>
</div>
</div>
</div>
)}
{/* Bottom row: Four-Eyes + Hard Blocks */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="text-center p-3 rounded-lg bg-yellow-50">
<div className="text-2xl font-bold text-yellow-700">{evidenceDistribution?.four_eyes_pending || 0}</div>
<div className="text-xs text-yellow-600 mt-1">Four-Eyes Reviews ausstehend</div>
</div>
{dashboard.multi_score?.hard_blocks && dashboard.multi_score.hard_blocks.length > 0 ? (
<div className="p-3 rounded-lg bg-red-50">
<div className="text-xs font-medium text-red-700 mb-1">Hard Blocks ({dashboard.multi_score.hard_blocks.length})</div>
<ul className="space-y-1">
{dashboard.multi_score.hard_blocks.slice(0, 3).map((block: string, i: number) => (
<li key={i} className="text-xs text-red-600 flex items-start gap-1">
<span className="text-red-400 mt-0.5">&#8226;</span>
<span>{block}</span>
</li>
))}
</ul>
</div>
) : (
<div className="text-center p-3 rounded-lg bg-green-50">
<div className="text-2xl font-bold text-green-700">0</div>
<div className="text-xs text-green-600 mt-1">Keine Hard Blocks</div>
</div>
)}
</div>
</div>
)}
{/* Next Actions + Findings */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Next Actions */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Naechste Aktionen</h3>
{nextActions.length === 0 ? (
<p className="text-sm text-slate-500">Keine offenen Aktionen.</p>
) : (
<div className="space-y-3">
{nextActions.map(action => (
<div key={action.id} className="flex items-center gap-3 p-3 rounded-lg bg-slate-50">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
action.days_overdue > 0 ? 'bg-red-500' : 'bg-yellow-500'
}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 truncate">{action.title}</p>
<p className="text-xs text-slate-500">
{action.control_id} · {DOMAIN_LABELS[action.domain] || action.domain}
{action.days_overdue > 0 && <span className="text-red-600 ml-2">{action.days_overdue}d ueberfaellig</span>}
</p>
</div>
<span className={`px-2 py-0.5 text-xs rounded-full ${
action.status === 'partial' ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-600'
}`}>
{action.status}
</span>
</div>
))}
</div>
)}
</div>
{/* Audit Findings */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Audit Findings</h3>
<Link href="/sdk/audit-checklist" className="text-sm text-purple-600 hover:text-purple-700">
Audit Checkliste
</Link>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-sm font-medium text-red-800">Hauptabweichungen</span>
</div>
<p className="text-3xl font-bold text-red-600">{findings?.open_majors || 0}</p>
<p className="text-xs text-red-600">offen (blockiert Zertifizierung)</p>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
<span className="text-sm font-medium text-yellow-800">Nebenabweichungen</span>
</div>
<p className="text-3xl font-bold text-yellow-600">{findings?.open_minors || 0}</p>
<p className="text-xs text-yellow-600">offen (erfordert CAPA)</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">
Gesamt: {findings?.total || 0} Findings ({findings?.major_count || 0} Major, {findings?.minor_count || 0} Minor, {findings?.ofi_count || 0} OFI)
</span>
{(findings?.open_majors || 0) === 0 ? (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">
Zertifizierung moeglich
</span>
) : (
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
Zertifizierung blockiert
</span>
)}
</div>
</div>
</div>
{/* Control-Mappings & Domain Chart */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Control-Mappings</h3>
<Link href="/sdk/controls" className="text-sm text-purple-600 hover:text-purple-700">
Alle anzeigen
</Link>
</div>
<div className="flex items-center gap-6 mb-4">
<div>
<p className="text-4xl font-bold text-purple-600">{mappings?.total || 0}</p>
<p className="text-sm text-slate-500">Mappings gesamt</p>
</div>
<div className="flex-1 h-16 bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500 mb-1">Nach Verordnung</p>
<div className="flex gap-1 flex-wrap">
{mappings?.by_regulation && Object.entries(mappings.by_regulation).slice(0, 5).map(([reg, count]) => (
<span key={reg} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{reg}: {count}
</span>
))}
{!mappings?.by_regulation && (
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-xs">Keine Mappings vorhanden</span>
)}
</div>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
<div className="space-y-2">
{Object.entries(dashboard?.controls_by_domain || {}).slice(0, 6).map(([domain, stats]) => {
const total = stats.total || 0
const pass = stats.pass || 0
const partial = stats.partial || 0
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
return (
<div key={domain} className="flex items-center gap-3">
<span className="text-xs font-medium text-slate-600 w-24 truncate">
{DOMAIN_LABELS[domain] || domain}
</span>
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${(pass / (total || 1)) * 100}%` }} />
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / (total || 1)) * 100}%` }} />
</div>
<span className="text-xs text-slate-500 w-16 text-right">{passPercent.toFixed(0)}%</span>
</div>
)
})}
</div>
</div>
</div>
{/* Regulations Table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards ({regulations.length})</h3>
<button onClick={loadData} className="text-sm text-purple-600 hover:text-purple-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{regulations.slice(0, 15).map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-purple-600">{reg.code}</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{reg.name}</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-700'
}`}>
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
reg.regulation_type === 'bsi_standard' ? 'BSI' :
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-medium">{reg.requirement_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import Link from 'next/link'
import { DashboardData } from '../_hooks/useComplianceHub'
export function QuickActions({ dashboard }: { dashboard: DashboardData | null }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
<Link
href="/sdk/audit-checklist"
className="p-4 rounded-lg border border-slate-200 hover:border-purple-500 hover:bg-purple-50 transition-colors text-center"
>
<div className="text-purple-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Audit Checkliste</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_requirements || '...'} Anforderungen</p>
</Link>
<Link
href="/sdk/controls"
className="p-4 rounded-lg border border-slate-200 hover:border-green-500 hover:bg-green-50 transition-colors text-center"
>
<div className="text-green-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Controls</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_controls || '...'} Massnahmen</p>
</Link>
<Link
href="/sdk/evidence"
className="p-4 rounded-lg border border-slate-200 hover:border-blue-500 hover:bg-blue-50 transition-colors text-center"
>
<div className="text-blue-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Evidence</p>
<p className="text-xs text-slate-500 mt-1">Nachweise</p>
</Link>
<Link
href="/sdk/risks"
className="p-4 rounded-lg border border-slate-200 hover:border-red-500 hover:bg-red-50 transition-colors text-center"
>
<div className="text-red-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Risk Matrix</p>
<p className="text-xs text-slate-500 mt-1">5x5 Risiken</p>
</Link>
<Link
href="/sdk/modules"
className="p-4 rounded-lg border border-slate-200 hover:border-pink-500 hover:bg-pink-50 transition-colors text-center"
>
<div className="text-pink-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Service Registry</p>
<p className="text-xs text-slate-500 mt-1">Module</p>
</Link>
<Link
href="/sdk/audit-report"
className="p-4 rounded-lg border border-slate-200 hover:border-orange-500 hover:bg-orange-50 transition-colors text-center"
>
<div className="text-orange-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Audit Report</p>
<p className="text-xs text-slate-500 mt-1">PDF Export</p>
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { Regulation } from '../_hooks/useComplianceHub'
export function RegulationsTable({
regulations,
onRefresh,
}: {
regulations: Regulation[]
onRefresh: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards ({regulations.length})</h3>
<button onClick={onRefresh} className="text-sm text-purple-600 hover:text-purple-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{regulations.slice(0, 15).map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-purple-600">{reg.code}</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{reg.name}</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-700'
}`}>
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
reg.regulation_type === 'bsi_standard' ? 'BSI' :
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-medium">{reg.requirement_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import type { RoadmapData } from './types'
import { BUCKET_LABELS, DOMAIN_LABELS } from './types'
interface RoadmapTabProps {
roadmap: RoadmapData | null
}
export function RoadmapTab({ roadmap }: RoadmapTabProps) {
if (!roadmap) {
return (
<div className="flex items-center justify-center h-48">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{(['quick_wins', 'must_have', 'should_have', 'nice_to_have'] as const).map(bucketKey => {
const meta = BUCKET_LABELS[bucketKey]
const items = roadmap.buckets[bucketKey] || []
return (
<div key={bucketKey} className={`rounded-xl border p-4 ${meta.bg}`}>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-semibold ${meta.color}`}>{meta.label}</h3>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full bg-white ${meta.color}`}>
{items.length}
</span>
</div>
<div className="space-y-2 max-h-96 overflow-y-auto">
{items.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-4">Keine Eintraege</p>
) : (
items.map(item => (
<div key={item.id} className="bg-white rounded-lg p-3 shadow-sm">
<p className="text-sm font-medium text-slate-900 truncate">{item.title}</p>
<div className="mt-1 flex items-center gap-2 text-xs text-slate-500">
<span className="font-mono">{item.control_id}</span>
<span>·</span>
<span>{DOMAIN_LABELS[item.domain] || item.domain}</span>
</div>
{item.days_overdue > 0 && (
<p className="mt-1 text-xs text-red-600">{item.days_overdue}d ueberfaellig</p>
)}
{item.owner && (
<p className="mt-1 text-xs text-slate-400">{item.owner}</p>
)}
</div>
))
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { DashboardData } from '../_hooks/useComplianceHub'
const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
export function StatsRow({
dashboard,
scoreColor,
scoreBgColor,
score,
}: {
dashboard: DashboardData | null
scoreColor: string
scoreBgColor: string
score: number
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
<div className={`text-5xl font-bold ${scoreColor}`}>
{score.toFixed(0)}%
</div>
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${scoreBgColor}`}
style={{ width: `${score}%` }}
/>
</div>
<p className="mt-2 text-sm text-slate-500">
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Verordnungen</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_regulations || 0}</p>
</div>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.total_requirements || 0} Anforderungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Controls</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_controls || 0}</p>
</div>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.controls_by_status?.pass || 0} bestanden</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Nachweise</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_evidence || 0}</p>
</div>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.evidence_by_status?.valid || 0} aktiv</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Risiken</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_risks || 0}</p>
</div>
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">
{(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch
</p>
</div>
</div>
)
}
export function DomainChart({ dashboard }: { dashboard: DashboardData | null }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(dashboard?.controls_by_domain || {}).map(([domain, stats]) => {
const total = stats.total || 0
const pass = stats.pass || 0
const partial = stats.partial || 0
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
return (
<div key={domain} className="p-3 rounded-lg bg-slate-50">
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">
{DOMAIN_LABELS[domain] || domain.toUpperCase()}
</span>
<span className="text-slate-500">
{pass}/{total} ({passPercent.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${(pass / total) * 100}%` }} />
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / total) * 100}%` }} />
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,253 @@
'use client'
import { ConfidenceLevelBadge } from '../../evidence/components/anti-fake-badges'
import type { TraceabilityMatrixData } from './types'
import { DOMAIN_LABELS } from './types'
interface TraceabilityTabProps {
traceabilityMatrix: TraceabilityMatrixData | null
traceabilityLoading: boolean
traceabilityFilter: 'all' | 'covered' | 'uncovered' | 'fully_verified'
setTraceabilityFilter: (f: 'all' | 'covered' | 'uncovered' | 'fully_verified') => void
traceabilityDomainFilter: string
setTraceabilityDomainFilter: (d: string) => void
expandedControls: Set<string>
expandedEvidence: Set<string>
toggleControlExpanded: (id: string) => void
toggleEvidenceExpanded: (id: string) => void
}
export function TraceabilityTab({
traceabilityMatrix, traceabilityLoading,
traceabilityFilter, setTraceabilityFilter,
traceabilityDomainFilter, setTraceabilityDomainFilter,
expandedControls, expandedEvidence,
toggleControlExpanded, toggleEvidenceExpanded,
}: TraceabilityTabProps) {
if (traceabilityLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
<span className="ml-3 text-slate-500">Traceability Matrix wird geladen...</span>
</div>
)
}
if (!traceabilityMatrix) {
return (
<div className="text-center py-12 text-slate-500">
Keine Daten verfuegbar. Stellen Sie sicher, dass Controls und Evidence vorhanden sind.
</div>
)
}
const summary = traceabilityMatrix.summary
const totalControls = summary.total_controls || 0
const covered = summary.covered || 0
const fullyVerified = summary.fully_verified || 0
const uncovered = summary.uncovered || 0
const filteredControls = (traceabilityMatrix.controls || []).filter(ctrl => {
if (traceabilityFilter === 'covered' && !ctrl.coverage.has_evidence) return false
if (traceabilityFilter === 'uncovered' && ctrl.coverage.has_evidence) return false
if (traceabilityFilter === 'fully_verified' && !ctrl.coverage.all_assertions_verified) return false
if (traceabilityDomainFilter !== 'all' && ctrl.domain !== traceabilityDomainFilter) return false
return true
})
const domains = [...new Set(traceabilityMatrix.controls.map(c => c.domain))].sort()
return (
<div className="p-6 space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="text-2xl font-bold text-purple-700">{totalControls}</div>
<div className="text-sm text-purple-600">Total Controls</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="text-2xl font-bold text-blue-700">{covered}</div>
<div className="text-sm text-blue-600">Abgedeckt</div>
</div>
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="text-2xl font-bold text-green-700">{fullyVerified}</div>
<div className="text-sm text-green-600">Vollst. verifiziert</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-2xl font-bold text-red-700">{uncovered}</div>
<div className="text-sm text-red-600">Unabgedeckt</div>
</div>
</div>
{/* Filter Bar */}
<div className="flex flex-wrap gap-4 items-center">
<div className="flex gap-1">
{([
{ key: 'all', label: 'Alle' },
{ key: 'covered', label: 'Abgedeckt' },
{ key: 'uncovered', label: 'Nicht abgedeckt' },
{ key: 'fully_verified', label: 'Vollst. verifiziert' },
] as const).map(f => (
<button
key={f.key}
onClick={() => setTraceabilityFilter(f.key)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
traceabilityFilter === f.key
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{f.label}
</button>
))}
</div>
<div className="h-4 w-px bg-slate-300" />
<div className="flex gap-1 flex-wrap">
<button
onClick={() => setTraceabilityDomainFilter('all')}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
traceabilityDomainFilter === 'all'
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Alle Domains
</button>
{domains.map(d => (
<button
key={d}
onClick={() => setTraceabilityDomainFilter(d)}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
traceabilityDomainFilter === d
? 'bg-purple-600 text-white'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{DOMAIN_LABELS[d] || d}
</button>
))}
</div>
</div>
{/* Controls List */}
<div className="space-y-2">
{filteredControls.length === 0 ? (
<div className="text-center py-8 text-slate-400">
Keine Controls fuer diesen Filter gefunden.
</div>
) : filteredControls.map(ctrl => {
const isExpanded = expandedControls.has(ctrl.id)
const coverageIcon = ctrl.coverage.all_assertions_verified
? { symbol: '\u2713', color: 'text-green-600 bg-green-50' }
: ctrl.coverage.has_evidence
? { symbol: '\u25D0', color: 'text-yellow-600 bg-yellow-50' }
: { symbol: '\u2717', color: 'text-red-600 bg-red-50' }
return (
<div key={ctrl.id} className="border rounded-lg overflow-hidden">
{/* Control Row */}
<button
onClick={() => toggleControlExpanded(ctrl.id)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors"
>
<span className="text-slate-400 text-xs">{isExpanded ? '\u25BC' : '\u25B6'}</span>
<span className={`w-7 h-7 flex items-center justify-center rounded-full text-sm font-medium ${coverageIcon.color}`}>
{coverageIcon.symbol}
</span>
<code className="text-xs bg-slate-100 px-2 py-0.5 rounded text-slate-600 font-mono">{ctrl.control_id}</code>
<span className="text-sm text-slate-800 flex-1 truncate">{ctrl.title}</span>
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded">{DOMAIN_LABELS[ctrl.domain] || ctrl.domain}</span>
<span className={`text-xs px-2 py-0.5 rounded ${
ctrl.status === 'implemented' ? 'bg-green-100 text-green-700'
: ctrl.status === 'in_progress' ? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-600'
}`}>
{ctrl.status}
</span>
<ConfidenceLevelBadge level={ctrl.coverage.min_confidence_level} />
<span className="text-xs text-slate-400 min-w-[3rem] text-right">
{ctrl.evidence.length} Ev.
</span>
</button>
{/* Expanded: Evidence list */}
{isExpanded && (
<div className="border-t bg-slate-50">
{ctrl.evidence.length === 0 ? (
<div className="px-8 py-3 text-xs text-slate-400 italic">
Kein Evidence verknuepft.
</div>
) : ctrl.evidence.map(ev => {
const evExpanded = expandedEvidence.has(ev.id)
return (
<div key={ev.id} className="border-b last:border-b-0">
<button
onClick={() => toggleEvidenceExpanded(ev.id)}
className="w-full flex items-center gap-3 px-8 py-2 text-left hover:bg-slate-100 transition-colors"
>
<span className="text-slate-400 text-xs">{evExpanded ? '\u25BC' : '\u25B6'}</span>
<span className="text-sm text-slate-700 flex-1 truncate">{ev.title}</span>
<span className="text-xs bg-white border px-2 py-0.5 rounded text-slate-500">{ev.evidence_type}</span>
<ConfidenceLevelBadge level={ev.confidence_level} />
<span className={`text-xs px-2 py-0.5 rounded ${
ev.status === 'valid' ? 'bg-green-100 text-green-700'
: ev.status === 'expired' ? 'bg-red-100 text-red-700'
: 'bg-slate-100 text-slate-600'
}`}>
{ev.status}
</span>
<span className="text-xs text-slate-400 min-w-[3rem] text-right">
{ev.assertions.length} Ass.
</span>
</button>
{/* Expanded: Assertions list */}
{evExpanded && ev.assertions.length > 0 && (
<div className="bg-white border-t">
<table className="w-full text-xs">
<thead className="bg-slate-50">
<tr>
<th className="px-12 py-1.5 text-left text-slate-500 font-medium">Aussage</th>
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-20">Typ</th>
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-24">Konfidenz</th>
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-16">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{ev.assertions.map(a => (
<tr key={a.id} className="hover:bg-slate-50">
<td className="px-12 py-1.5 text-slate-700">{a.sentence_text}</td>
<td className="px-3 py-1.5 text-center text-slate-500">{a.assertion_type}</td>
<td className="px-3 py-1.5 text-center">
<span className={`font-medium ${
a.confidence >= 0.8 ? 'text-green-600'
: a.confidence >= 0.5 ? 'text-yellow-600'
: 'text-red-600'
}`}>
{(a.confidence * 100).toFixed(0)}%
</span>
</td>
<td className="px-3 py-1.5 text-center">
{a.verified
? <span className="text-green-600 font-medium">{'\u2713'}</span>
: <span className="text-slate-400">{'\u2717'}</span>
}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import type { ScoreSnapshot } from './types'
interface TrendTabProps {
scoreHistory: ScoreSnapshot[]
savingSnapshot: boolean
saveSnapshot: () => Promise<void>
}
export function TrendTab({ scoreHistory, savingSnapshot, saveSnapshot }: TrendTabProps) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-slate-900">Score-Verlauf</h3>
<button
onClick={saveSnapshot}
disabled={savingSnapshot}
className="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{savingSnapshot ? 'Speichere...' : 'Aktuellen Score speichern'}
</button>
</div>
{scoreHistory.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-500">Noch keine Score-Snapshots vorhanden.</p>
<p className="text-sm text-slate-400 mt-1">Klicken Sie auf "Aktuellen Score speichern", um den ersten Datenpunkt zu erstellen.</p>
</div>
) : (
<>
{/* Simple SVG Line Chart */}
<div className="relative h-64 mb-6">
<svg className="w-full h-full" viewBox="0 0 800 200" preserveAspectRatio="none">
{/* Grid lines */}
{[0, 25, 50, 75, 100].map(pct => (
<line key={pct} x1="0" y1={200 - pct * 2} x2="800" y2={200 - pct * 2}
stroke="#e2e8f0" strokeWidth="1" />
))}
{/* Score line */}
<polyline
fill="none"
stroke="#9333ea"
strokeWidth="3"
strokeLinejoin="round"
points={scoreHistory.map((s, i) => {
const x = scoreHistory.length === 1 ? 400 : (i / (scoreHistory.length - 1)) * 780 + 10
const y = 200 - (s.score / 100) * 200
return `${x},${y}`
}).join(' ')}
/>
{/* Points */}
{scoreHistory.map((s, i) => {
const x = scoreHistory.length === 1 ? 400 : (i / (scoreHistory.length - 1)) * 780 + 10
const y = 200 - (s.score / 100) * 200
return (
<circle key={i} cx={x} cy={y} r="5" fill="#9333ea" stroke="white" strokeWidth="2" />
)
})}
</svg>
{/* Y-axis labels */}
<div className="absolute left-0 top-0 h-full flex flex-col justify-between text-xs text-slate-400 -ml-2">
<span>100%</span>
<span>75%</span>
<span>50%</span>
<span>25%</span>
<span>0%</span>
</div>
</div>
{/* Snapshot Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-slate-500 uppercase">Datum</th>
<th className="px-4 py-2 text-center text-xs font-medium text-slate-500 uppercase">Score</th>
<th className="px-4 py-2 text-center text-xs font-medium text-slate-500 uppercase">Controls</th>
<th className="px-4 py-2 text-center text-xs font-medium text-slate-500 uppercase">Bestanden</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{scoreHistory.slice().reverse().map(snap => (
<tr key={snap.id} className="hover:bg-slate-50">
<td className="px-4 py-2 text-slate-700">{new Date(snap.snapshot_date).toLocaleDateString('de-DE')}</td>
<td className="px-4 py-2 text-center">
<span className={`font-bold ${
snap.score >= 80 ? 'text-green-600' : snap.score >= 60 ? 'text-yellow-600' : 'text-red-600'
}`}>
{typeof snap.score === 'number' ? snap.score.toFixed(1) : snap.score}%
</span>
</td>
<td className="px-4 py-2 text-center text-slate-600">{snap.controls_total}</td>
<td className="px-4 py-2 text-center text-slate-600">{snap.controls_pass}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,169 @@
// Shared types for Compliance Hub
export interface DashboardData {
compliance_score: number
total_regulations: number
total_requirements: number
total_controls: number
controls_by_status: Record<string, number>
controls_by_domain: Record<string, Record<string, number>>
total_evidence: number
evidence_by_status: Record<string, number>
total_risks: number
risks_by_level: Record<string, number>
multi_score?: {
requirement_coverage: number
evidence_strength: number
validation_quality: number
evidence_freshness: number
control_effectiveness: number
overall_readiness: number
hard_blocks: string[]
} | null
}
export interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
effective_date: string | null
description: string
requirement_count: number
}
export interface MappingsData {
total: number
by_regulation: Record<string, number>
}
export interface FindingsData {
major_count: number
minor_count: number
ofi_count: number
total: number
open_majors: number
open_minors: number
}
export interface RoadmapItem {
id: string
control_id: string
title: string
status: string
domain: string
owner: string | null
next_review_at: string | null
days_overdue: number
weight: number
}
export interface RoadmapData {
buckets: Record<string, RoadmapItem[]>
counts: Record<string, number>
}
export interface ModuleInfo {
key: string
label: string
count: number
status: string
progress: number
}
export interface ModuleStatusData {
modules: ModuleInfo[]
total: number
started: number
complete: number
overall_progress: number
}
export interface NextAction {
id: string
control_id: string
title: string
status: string
domain: string
owner: string | null
days_overdue: number
urgency_score: number
reason: string
}
export interface ScoreSnapshot {
id: string
score: number
controls_total: number
controls_pass: number
snapshot_date: string
created_at: string
}
export interface TraceabilityAssertion {
id: string
sentence_text: string
assertion_type: string
confidence: number
verified: boolean
}
export interface TraceabilityEvidence {
id: string
title: string
evidence_type: string
confidence_level: string
status: string
assertions: TraceabilityAssertion[]
}
export interface TraceabilityCoverage {
has_evidence: boolean
has_assertions: boolean
all_assertions_verified: boolean
min_confidence_level: string | null
}
export interface TraceabilityControl {
id: string
control_id: string
title: string
status: string
domain: string
evidence: TraceabilityEvidence[]
coverage: TraceabilityCoverage
}
export interface TraceabilityMatrixData {
controls: TraceabilityControl[]
summary: Record<string, number>
}
export type TabKey = 'overview' | 'roadmap' | 'modules' | 'trend' | 'traceability'
export const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
export const BUCKET_LABELS: Record<string, { label: string; color: string; bg: string }> = {
quick_wins: { label: 'Quick Wins', color: 'text-green-700', bg: 'bg-green-50 border-green-200' },
must_have: { label: 'Must Have', color: 'text-red-700', bg: 'bg-red-50 border-red-200' },
should_have: { label: 'Should Have', color: 'text-yellow-700', bg: 'bg-yellow-50 border-yellow-200' },
nice_to_have: { label: 'Nice to Have', color: 'text-slate-700', bg: 'bg-slate-50 border-slate-200' },
}
export const MODULE_ICONS: Record<string, string> = {
vvt: '📋', tom: '🔒', dsfa: '⚠️', loeschfristen: '🗑️', risks: '🎯',
controls: '✅', evidence: '📎', obligations: '📜', incidents: '🚨',
vendor: '🤝', legal_templates: '📄', training: '🎓', audit: '🔍',
security_backlog: '🛡️', quality: '⭐',
}

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