fix(admin-v2): Restore HEAD SDK files for compatibility with new pages

Restore the SDK context, types, and component files to the HEAD version
since newer pages (company-profile, import) depend on these API changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 10:18:39 +01:00
parent 613b36be83
commit bd70b59c5e
15 changed files with 1054 additions and 2921 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
/**
* Document Generator Components
*
* Diese Komponenten integrieren die Einwilligungen-Datenpunkte
* in den Dokumentengenerator.
*/
export { DataPointsPreview } from './DataPointsPreview'
export { DocumentValidation } from './DocumentValidation'

View File

@@ -1,793 +0,0 @@
'use client'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useSDK } from '@/lib/sdk'
import { useEinwilligungen } from '@/lib/sdk/einwilligungen/context'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
LegalTemplateResult,
TemplateType,
Jurisdiction,
LicenseType,
GeneratedDocument,
TEMPLATE_TYPE_LABELS,
LICENSE_TYPE_LABELS,
JURISDICTION_LABELS,
DEFAULT_PLACEHOLDERS,
} from '@/lib/sdk/types'
import { DataPointsPreview } from './components/DataPointsPreview'
import { DocumentValidation } from './components/DocumentValidation'
import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-helpers'
// =============================================================================
// API CLIENT
// =============================================================================
const KLAUSUR_SERVICE_URL = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
async function searchTemplates(params: {
query: string
templateType?: TemplateType
licenseTypes?: LicenseType[]
language?: 'de' | 'en'
jurisdiction?: Jurisdiction
limit?: number
}): Promise<LegalTemplateResult[]> {
const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: params.query,
template_type: params.templateType,
license_types: params.licenseTypes,
language: params.language,
jurisdiction: params.jurisdiction,
limit: params.limit || 10,
}),
})
if (!response.ok) {
throw new Error('Search failed')
}
const data = await response.json()
return data.map((r: any) => ({
id: r.id,
score: r.score,
text: r.text,
documentTitle: r.document_title,
templateType: r.template_type,
clauseCategory: r.clause_category,
language: r.language,
jurisdiction: r.jurisdiction,
licenseId: r.license_id,
licenseName: r.license_name,
licenseUrl: r.license_url,
attributionRequired: r.attribution_required,
attributionText: r.attribution_text,
sourceName: r.source_name,
sourceUrl: r.source_url,
sourceRepo: r.source_repo,
placeholders: r.placeholders || [],
isCompleteDocument: r.is_complete_document,
isModular: r.is_modular,
requiresCustomization: r.requires_customization,
outputAllowed: r.output_allowed ?? true,
modificationAllowed: r.modification_allowed ?? true,
distortionProhibited: r.distortion_prohibited ?? false,
}))
}
async function getTemplatesStatus(): Promise<any> {
const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/status`)
if (!response.ok) return null
return response.json()
}
async function getSources(): Promise<any[]> {
const response = await fetch(`${KLAUSUR_SERVICE_URL}/api/v1/admin/templates/sources`)
if (!response.ok) return []
const data = await response.json()
return data.sources || []
}
// =============================================================================
// COMPONENTS
// =============================================================================
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
ready: 'bg-green-100 text-green-700',
empty: 'bg-yellow-100 text-yellow-700',
error: 'bg-red-100 text-red-700',
running: 'bg-blue-100 text-blue-700',
}
return (
<span className={`px-2 py-1 text-xs rounded-full ${colors[status] || 'bg-gray-100 text-gray-700'}`}>
{status}
</span>
)
}
function LicenseBadge({ licenseId, small = false }: { licenseId: LicenseType | null; small?: boolean }) {
if (!licenseId) return null
const colors: Record<LicenseType, string> = {
public_domain: 'bg-green-100 text-green-700 border-green-200',
cc0: 'bg-green-100 text-green-700 border-green-200',
unlicense: 'bg-green-100 text-green-700 border-green-200',
mit: 'bg-blue-100 text-blue-700 border-blue-200',
cc_by_4: 'bg-purple-100 text-purple-700 border-purple-200',
reuse_notice: 'bg-orange-100 text-orange-700 border-orange-200',
}
return (
<span className={`${small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs'} rounded border ${colors[licenseId]}`}>
{LICENSE_TYPE_LABELS[licenseId] || licenseId}
</span>
)
}
function TemplateCard({
template,
selected,
onSelect,
}: {
template: LegalTemplateResult
selected: boolean
onSelect: () => void
}) {
return (
<div
onClick={onSelect}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
selected
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-purple-300 bg-white'
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900">
{template.documentTitle || 'Untitled'}
</span>
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
{template.score.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{template.templateType && (
<span className="text-xs text-purple-600 bg-purple-100 px-2 py-0.5 rounded">
{TEMPLATE_TYPE_LABELS[template.templateType as TemplateType] || template.templateType}
</span>
)}
<LicenseBadge licenseId={template.licenseId as LicenseType} small />
<span className="text-xs text-gray-500 uppercase">
{template.language}
</span>
</div>
</div>
<input
type="checkbox"
checked={selected}
onChange={onSelect}
className="w-5 h-5 text-purple-600 rounded border-gray-300"
/>
</div>
<p className="text-sm text-gray-600 line-clamp-3 mt-2">
{template.text}
</p>
{template.attributionRequired && template.attributionText && (
<div className="mt-2 text-xs text-orange-600 bg-orange-50 p-2 rounded">
Attribution: {template.attributionText}
</div>
)}
{template.placeholders && template.placeholders.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{template.placeholders.slice(0, 5).map((p, i) => (
<span key={i} className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">
{p}
</span>
))}
{template.placeholders.length > 5 && (
<span className="text-xs text-gray-500">
+{template.placeholders.length - 5} more
</span>
)}
</div>
)}
<div className="mt-3 text-xs text-gray-500">
Source: {template.sourceName}
</div>
</div>
)
}
function PlaceholderEditor({
placeholders,
values,
onChange,
}: {
placeholders: string[]
values: Record<string, string>
onChange: (key: string, value: string) => void
}) {
if (placeholders.length === 0) return null
return (
<div className="bg-blue-50 rounded-xl p-4 border border-blue-200">
<h4 className="font-medium text-blue-900 mb-3">Platzhalter ausfuellen</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{placeholders.map((placeholder) => (
<div key={placeholder}>
<label className="block text-sm text-blue-700 mb-1">{placeholder}</label>
<input
type="text"
value={values[placeholder] || ''}
onChange={(e) => onChange(placeholder, e.target.value)}
placeholder={`Wert fuer ${placeholder}`}
className="w-full px-3 py-2 border border-blue-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
))}
</div>
</div>
)
}
function AttributionFooter({ templates }: { templates: LegalTemplateResult[] }) {
const attributionTemplates = templates.filter((t) => t.attributionRequired)
if (attributionTemplates.length === 0) return null
return (
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
<h4 className="font-medium text-gray-900 mb-2">Quellenangaben (werden automatisch hinzugefuegt)</h4>
<div className="text-sm text-gray-600 space-y-1">
<p>Dieses Dokument wurde unter Verwendung folgender Quellen erstellt:</p>
<ul className="list-disc list-inside ml-2">
{attributionTemplates.map((t, i) => (
<li key={i}>
{t.attributionText || `${t.sourceName} (${t.licenseName})`}
</li>
))}
</ul>
</div>
</div>
)
}
function DocumentPreview({
content,
placeholders,
}: {
content: string
placeholders: Record<string, string>
}) {
// Replace placeholders in content
let processedContent = content
for (const [key, value] of Object.entries(placeholders)) {
if (value) {
processedContent = processedContent.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
}
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6 prose prose-sm max-w-none">
<div className="whitespace-pre-wrap">{processedContent}</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function DocumentGeneratorPage() {
const { state } = useSDK()
const { selectedDataPointsData } = useEinwilligungen()
// Status state
const [status, setStatus] = useState<any>(null)
const [sources, setSources] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(true)
// Search state
const [searchQuery, setSearchQuery] = useState('')
const [selectedType, setSelectedType] = useState<TemplateType | ''>('')
const [selectedLanguage, setSelectedLanguage] = useState<'de' | 'en' | ''>('')
const [selectedJurisdiction, setSelectedJurisdiction] = useState<Jurisdiction | ''>('')
const [searchResults, setSearchResults] = useState<LegalTemplateResult[]>([])
const [isSearching, setIsSearching] = useState(false)
// Selection state
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([])
// Editor state
const [placeholderValues, setPlaceholderValues] = useState<Record<string, string>>({})
const [activeTab, setActiveTab] = useState<'search' | 'compose' | 'preview'>('search')
// Load initial status
useEffect(() => {
async function loadStatus() {
try {
const [statusData, sourcesData] = await Promise.all([
getTemplatesStatus(),
getSources(),
])
setStatus(statusData)
setSources(sourcesData)
} catch (error) {
console.error('Failed to load status:', error)
} finally {
setIsLoading(false)
}
}
loadStatus()
}, [])
// Pre-fill placeholders from company profile
useEffect(() => {
if (state?.companyProfile) {
const profile = state.companyProfile
setPlaceholderValues((prev) => ({
...prev,
'[COMPANY_NAME]': profile.companyName || '',
'[FIRMENNAME]': profile.companyName || '',
'[EMAIL]': profile.dpoEmail || '',
'[DSB_EMAIL]': profile.dpoEmail || '',
'[DPO_NAME]': profile.dpoName || '',
'[DSB_NAME]': profile.dpoName || '',
}))
}
}, [state?.companyProfile])
// Pre-fill placeholders from Einwilligungen data points
useEffect(() => {
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
const einwilligungenPlaceholders = generateAllPlaceholders(selectedDataPointsData, 'de')
setPlaceholderValues((prev) => ({
...prev,
...einwilligungenPlaceholders,
}))
}
}, [selectedDataPointsData])
// Handler for inserting placeholders from DataPointsPreview
const handleInsertPlaceholder = useCallback((placeholder: string) => {
// This is a simplified version - in a real editor you would insert at cursor position
// For now, we just ensure the placeholder is in the values so it can be replaced
if (!placeholderValues[placeholder]) {
// The placeholder value will be generated from einwilligungen data
const einwilligungenPlaceholders = generateAllPlaceholders(selectedDataPointsData || [], 'de')
if (einwilligungenPlaceholders[placeholder as keyof typeof einwilligungenPlaceholders]) {
setPlaceholderValues((prev) => ({
...prev,
[placeholder]: einwilligungenPlaceholders[placeholder as keyof typeof einwilligungenPlaceholders],
}))
}
}
}, [placeholderValues, selectedDataPointsData])
// Search handler
const handleSearch = useCallback(async () => {
if (!searchQuery.trim()) return
setIsSearching(true)
try {
const results = await searchTemplates({
query: searchQuery,
templateType: selectedType || undefined,
language: selectedLanguage || undefined,
jurisdiction: selectedJurisdiction || undefined,
limit: 20,
})
setSearchResults(results)
} catch (error) {
console.error('Search failed:', error)
} finally {
setIsSearching(false)
}
}, [searchQuery, selectedType, selectedLanguage, selectedJurisdiction])
// Toggle template selection
const toggleTemplate = (id: string) => {
setSelectedTemplates((prev) =>
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
)
}
// Get selected template objects
const selectedTemplateObjects = searchResults.filter((r) =>
selectedTemplates.includes(r.id)
)
// Get all unique placeholders from selected templates
const allPlaceholders = Array.from(
new Set(selectedTemplateObjects.flatMap((t) => t.placeholders || []))
)
// Combined content from selected templates
const combinedContent = selectedTemplateObjects
.map((t) => `## ${t.documentTitle || 'Abschnitt'}\n\n${t.text}`)
.join('\n\n---\n\n')
// Step info - using 'consent' as base since document-generator doesn't exist yet
const stepInfo = STEP_EXPLANATIONS['consent'] || {
title: 'Dokumentengenerator',
description: 'Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen',
explanation: 'Der Dokumentengenerator nutzt frei lizenzierte Textbausteine um Datenschutzerklaerungen, AGB und andere rechtliche Dokumente zu erstellen.',
tips: ['Waehlen Sie passende Vorlagen aus der Suche', 'Fuellen Sie die Platzhalter mit Ihren Unternehmensdaten'],
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
)
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="document-generator"
title="Dokumentengenerator"
description="Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen"
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<button
onClick={() => setActiveTab('compose')}
disabled={selectedTemplates.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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>
Dokument erstellen ({selectedTemplates.length})
</button>
</StepHeader>
{/* Status Overview */}
<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">Collection Status</div>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={status?.stats?.status || 'unknown'} />
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Indexierte Chunks</div>
<div className="text-3xl font-bold text-gray-900">
{status?.stats?.points_count || 0}
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Aktive Quellen</div>
<div className="text-3xl font-bold text-purple-600">
{sources.filter((s) => s.enabled).length}
</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Ausgewaehlt</div>
<div className="text-3xl font-bold text-blue-600">
{selectedTemplates.length}
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="flex gap-2 border-b border-gray-200">
{(['search', 'compose', 'preview'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
disabled={tab !== 'search' && selectedTemplates.length === 0}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === tab
? 'text-purple-600 border-b-2 border-purple-600'
: 'text-gray-500 hover:text-gray-700 disabled:text-gray-300 disabled:cursor-not-allowed'
}`}
>
{tab === 'search' && 'Vorlagen suchen'}
{tab === 'compose' && 'Zusammenstellen'}
{tab === 'preview' && 'Vorschau'}
</button>
))}
</div>
{/* Search Tab */}
{activeTab === 'search' && (
<div className="space-y-4">
{/* Search Form */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex gap-4 items-end flex-wrap">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
Suche
</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="z.B. Datenschutzerklaerung, Cookie-Banner, Widerruf..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Dokumenttyp
</label>
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value as TemplateType | '')}
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Alle Typen</option>
{Object.entries(TEMPLATE_TYPE_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sprache
</label>
<select
value={selectedLanguage}
onChange={(e) => setSelectedLanguage(e.target.value as 'de' | 'en' | '')}
className="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
>
<option value="">Alle</option>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
<button
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim()}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
{isSearching ? 'Suche...' : 'Suchen'}
</button>
</div>
</div>
{/* Search Results */}
{searchResults.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900">
{searchResults.length} Ergebnisse
</h3>
{selectedTemplates.length > 0 && (
<button
onClick={() => setSelectedTemplates([])}
className="text-sm text-gray-500 hover:text-gray-700"
>
Auswahl aufheben
</button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{searchResults.map((result) => (
<TemplateCard
key={result.id}
template={result}
selected={selectedTemplates.includes(result.id)}
onSelect={() => toggleTemplate(result.id)}
/>
))}
</div>
</div>
)}
{searchResults.length === 0 && searchQuery && !isSearching && (
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Vorlagen gefunden</h3>
<p className="mt-2 text-gray-500">
Versuchen Sie einen anderen Suchbegriff oder aendern Sie die Filter.
</p>
</div>
)}
{/* Quick Start Templates */}
{searchResults.length === 0 && !searchQuery && (
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Schnellstart - Haeufig benoetigte Dokumente</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ query: 'Datenschutzerklaerung DSGVO', type: 'privacy_policy', icon: '🔒' },
{ query: 'Cookie Banner', type: 'cookie_banner', icon: '🍪' },
{ query: 'Impressum', type: 'impressum', icon: '📋' },
{ query: 'AGB Nutzungsbedingungen', type: 'terms_of_service', icon: '📜' },
].map((item) => (
<button
key={item.type}
onClick={() => {
setSearchQuery(item.query)
setSelectedType(item.type as TemplateType)
setTimeout(handleSearch, 100)
}}
className="p-4 bg-white rounded-lg border border-gray-200 hover:border-purple-300 hover:shadow transition-all text-center"
>
<span className="text-3xl mb-2 block">{item.icon}</span>
<span className="text-sm font-medium text-gray-900">
{TEMPLATE_TYPE_LABELS[item.type as TemplateType]}
</span>
</button>
))}
</div>
</div>
)}
</div>
)}
{/* Compose Tab */}
{activeTab === 'compose' && selectedTemplates.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content - 2/3 */}
<div className="lg:col-span-2 space-y-6">
{/* Selected Templates */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">
Ausgewaehlte Bausteine ({selectedTemplates.length})
</h3>
<div className="space-y-2">
{selectedTemplateObjects.map((t, index) => (
<div
key={t.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<span className="text-gray-400 font-mono">{index + 1}.</span>
<span className="font-medium">{t.documentTitle}</span>
<LicenseBadge licenseId={t.licenseId as LicenseType} small />
</div>
<button
onClick={() => toggleTemplate(t.id)}
className="text-red-500 hover:text-red-700"
>
<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>
</div>
{/* Placeholder Editor */}
<PlaceholderEditor
placeholders={allPlaceholders}
values={placeholderValues}
onChange={(key, value) =>
setPlaceholderValues((prev) => ({ ...prev, [key]: value }))
}
/>
{/* Attribution Footer */}
<AttributionFooter templates={selectedTemplateObjects} />
{/* Actions */}
<div className="flex justify-end gap-4">
<button
onClick={() => setActiveTab('search')}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Zurueck zur Suche
</button>
<button
onClick={() => setActiveTab('preview')}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Vorschau anzeigen
</button>
</div>
</div>
{/* Sidebar - 1/3: Einwilligungen DataPoints */}
<div className="lg:col-span-1">
<DataPointsPreview
dataPoints={selectedDataPointsData || []}
onInsertPlaceholder={handleInsertPlaceholder}
language="de"
/>
</div>
</div>
)}
{/* Preview Tab */}
{activeTab === 'preview' && selectedTemplates.length > 0 && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900">Dokument-Vorschau</h3>
<div className="flex gap-2">
<button
onClick={() => setActiveTab('compose')}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Bearbeiten
</button>
<button
onClick={() => {
// Copy to clipboard
let content = combinedContent
for (const [key, value] of Object.entries(placeholderValues)) {
if (value) {
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
}
}
navigator.clipboard.writeText(content)
}}
className="px-4 py-2 text-purple-700 bg-purple-100 rounded-lg hover:bg-purple-200 transition-colors"
>
Kopieren
</button>
<button
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Als PDF exportieren
</button>
</div>
</div>
{/* Document Validation based on selected Einwilligungen */}
{selectedDataPointsData && selectedDataPointsData.length > 0 && (
<DocumentValidation
dataPoints={selectedDataPointsData}
documentContent={combinedContent}
language="de"
onInsertPlaceholder={handleInsertPlaceholder}
/>
)}
<DocumentPreview
content={combinedContent}
placeholders={placeholderValues}
/>
{/* Attribution */}
<AttributionFooter templates={selectedTemplateObjects} />
</div>
)}
{/* Sources Info */}
{activeTab === 'search' && sources.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Verfuegbare Quellen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sources.filter((s) => s.enabled).slice(0, 6).map((source) => (
<div key={source.name} className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-gray-900">{source.name}</span>
<LicenseBadge licenseId={source.license_type as LicenseType} small />
</div>
<p className="text-sm text-gray-600 line-clamp-2">{source.description}</p>
<div className="mt-2 flex flex-wrap gap-1">
{source.template_types.slice(0, 3).map((t: string) => (
<span key={t} className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
{TEMPLATE_TYPE_LABELS[t as TemplateType] || t}
</span>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -1,177 +1,179 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { DSFACard } from '@/components/sdk/dsfa'
import {
DSFA,
DSFAStatus,
DSFA_STATUS_LABELS,
DSFA_RISK_LEVEL_LABELS,
} from '@/lib/sdk/dsfa/types'
import {
listDSFAs,
deleteDSFA,
exportDSFAAsJSON,
getDSFAStats,
createDSFAFromAssessment,
getDSFAByAssessment,
} from '@/lib/sdk/dsfa/api'
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
// =============================================================================
// UCCA TRIGGER WARNING COMPONENT
// TYPES
// =============================================================================
interface UCCATriggerWarningProps {
assessmentId: string
triggeredRules: string[]
existingDsfaId?: string
onCreateDSFA: () => void
interface DSFA {
id: string
title: string
description: string
status: 'draft' | 'in-review' | 'approved' | 'needs-update'
createdAt: Date
updatedAt: Date
approvedBy: string | null
riskLevel: 'low' | 'medium' | 'high' | 'critical'
processingActivity: string
dataCategories: string[]
recipients: string[]
measures: string[]
}
function UCCATriggerWarning({
assessmentId,
triggeredRules,
existingDsfaId,
onCreateDSFA,
}: UCCATriggerWarningProps) {
if (existingDsfaId) {
return (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex items-start gap-4">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1">
<h4 className="font-medium text-blue-800">DSFA bereits erstellt</h4>
<p className="text-sm text-blue-600 mt-1">
Fuer dieses Assessment wurde bereits eine DSFA angelegt.
</p>
<Link
href={`/sdk/dsfa/${existingDsfaId}`}
className="inline-flex items-center gap-1 mt-2 text-sm text-blue-700 hover:text-blue-800 font-medium"
>
DSFA oeffnen
<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>
</Link>
</div>
</div>
)
// =============================================================================
// MOCK DATA
// =============================================================================
const mockDSFAs: DSFA[] = [
{
id: 'dsfa-1',
title: 'DSFA - Bewerber-Management-System',
description: 'Datenschutz-Folgenabschaetzung fuer das KI-gestuetzte Bewerber-Screening',
status: 'in-review',
createdAt: new Date('2024-01-10'),
updatedAt: new Date('2024-01-20'),
approvedBy: null,
riskLevel: 'high',
processingActivity: 'Automatisierte Bewertung von Bewerbungsunterlagen',
dataCategories: ['Kontaktdaten', 'Beruflicher Werdegang', 'Qualifikationen'],
recipients: ['HR-Abteilung', 'Fachabteilungen'],
measures: ['Verschluesselung', 'Zugriffskontrolle', 'Menschliche Pruefung'],
},
{
id: 'dsfa-2',
title: 'DSFA - Video-Ueberwachung Buero',
description: 'Datenschutz-Folgenabschaetzung fuer die Videoueberwachung im Buerogebaeude',
status: 'approved',
createdAt: new Date('2023-11-01'),
updatedAt: new Date('2023-12-15'),
approvedBy: 'DSB Mueller',
riskLevel: 'medium',
processingActivity: 'Videoueberwachung zu Sicherheitszwecken',
dataCategories: ['Bilddaten', 'Bewegungsdaten'],
recipients: ['Sicherheitsdienst'],
measures: ['Loeschfristen', 'Zugriffsbeschraenkung', 'Hinweisschilder'],
},
{
id: 'dsfa-3',
title: 'DSFA - Kundenanalyse',
description: 'Datenschutz-Folgenabschaetzung fuer Big-Data-Kundenanalysen',
status: 'draft',
createdAt: new Date('2024-01-22'),
updatedAt: new Date('2024-01-22'),
approvedBy: null,
riskLevel: 'high',
processingActivity: 'Analyse von Kundenverhalten fuer Marketing',
dataCategories: ['Kaufhistorie', 'Nutzungsverhalten', 'Praeferenzen'],
recipients: ['Marketing', 'Vertrieb'],
measures: [],
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
function DSFACard({ dsfa }: { dsfa: DSFA }) {
const statusColors = {
draft: 'bg-gray-100 text-gray-600 border-gray-200',
'in-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
approved: 'bg-green-100 text-green-700 border-green-200',
'needs-update': 'bg-orange-100 text-orange-700 border-orange-200',
}
const statusLabels = {
draft: 'Entwurf',
'in-review': 'In Pruefung',
approved: 'Genehmigt',
'needs-update': 'Aktualisierung erforderlich',
}
const riskColors = {
low: 'bg-green-100 text-green-700',
medium: 'bg-yellow-100 text-yellow-700',
high: 'bg-orange-100 text-orange-700',
critical: 'bg-red-100 text-red-700',
}
return (
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4 flex items-start gap-4">
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-orange-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-orange-800">DSFA erforderlich</h4>
<p className="text-sm text-orange-600 mt-1">
Das UCCA-Assessment hat folgende Trigger ausgeloest:
</p>
<div className="flex flex-wrap gap-1 mt-2">
{triggeredRules.map(rule => (
<span key={rule} className="px-2 py-0.5 text-xs bg-orange-100 text-orange-700 rounded">
{rule}
<div className={`bg-white rounded-xl border-2 p-6 ${
dsfa.status === 'needs-update' ? 'border-orange-200' :
dsfa.status === 'approved' ? 'border-green-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 ${statusColors[dsfa.status]}`}>
{statusLabels[dsfa.status]}
</span>
))}
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[dsfa.riskLevel]}`}>
Risiko: {dsfa.riskLevel === 'low' ? 'Niedrig' :
dsfa.riskLevel === 'medium' ? 'Mittel' :
dsfa.riskLevel === 'high' ? 'Hoch' : 'Kritisch'}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">{dsfa.title}</h3>
<p className="text-sm text-gray-500 mt-1">{dsfa.description}</p>
</div>
</div>
<div className="mt-4 text-sm text-gray-600">
<p><span className="text-gray-500">Verarbeitungstaetigkeit:</span> {dsfa.processingActivity}</p>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{dsfa.dataCategories.map(cat => (
<span key={cat} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{cat}
</span>
))}
</div>
{dsfa.measures.length > 0 && (
<div className="mt-3">
<span className="text-sm text-gray-500">Massnahmen:</span>
<div className="flex flex-wrap gap-1 mt-1">
{dsfa.measures.map(m => (
<span key={m} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
{m}
</span>
))}
</div>
</div>
)}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<div className="text-gray-500">
<span>Erstellt: {dsfa.createdAt.toLocaleDateString('de-DE')}</span>
{dsfa.approvedBy && (
<span className="ml-4">Genehmigt von: {dsfa.approvedBy}</span>
)}
</div>
<div className="flex items-center gap-2">
<button className="px-3 py-1 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</button>
<button className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Exportieren
</button>
</div>
<button
onClick={onCreateDSFA}
className="inline-flex items-center gap-1 mt-3 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium"
>
DSFA aus Assessment erstellen
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)
}
// =============================================================================
// GENERATOR WIZARD COMPONENT
// =============================================================================
function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreated: (dsfa: DSFA) => void }) {
function GeneratorWizard({ onClose }: { onClose: () => void }) {
const [step, setStep] = useState(1)
const [formData, setFormData] = useState({
name: '',
description: '',
processingPurpose: '',
dataCategories: [] as string[],
legalBasis: '',
})
const [isSubmitting, setIsSubmitting] = useState(false)
const DATA_CATEGORIES = [
'Kontaktdaten',
'Identifikationsdaten',
'Finanzdaten',
'Gesundheitsdaten',
'Standortdaten',
'Nutzungsdaten',
'Biometrische Daten',
'Daten Minderjaehriger',
]
const LEGAL_BASES = [
{ value: 'consent', label: 'Einwilligung (Art. 6 Abs. 1 lit. a)' },
{ value: 'contract', label: 'Vertrag (Art. 6 Abs. 1 lit. b)' },
{ value: 'legal_obligation', label: 'Rechtliche Verpflichtung (Art. 6 Abs. 1 lit. c)' },
{ value: 'legitimate_interest', label: 'Berechtigtes Interesse (Art. 6 Abs. 1 lit. f)' },
]
const handleCategoryToggle = (cat: string) => {
setFormData(prev => ({
...prev,
dataCategories: prev.dataCategories.includes(cat)
? prev.dataCategories.filter(c => c !== cat)
: [...prev.dataCategories, cat],
}))
}
const handleSubmit = async () => {
setIsSubmitting(true)
try {
// For standalone DSFA, we use the regular create endpoint
const response = await fetch('/api/sdk/v1/dsgvo/dsfas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name,
description: formData.description,
processing_purpose: formData.processingPurpose,
data_categories: formData.dataCategories,
legal_basis: formData.legalBasis,
status: 'draft',
}),
})
if (response.ok) {
const dsfa = await response.json()
onCreated(dsfa)
onClose()
}
} catch (error) {
console.error('Failed to create DSFA:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900">Neue Standalone-DSFA erstellen</h3>
<h3 className="text-lg font-semibold text-gray-900">Neue DSFA erstellen</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -181,7 +183,7 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
{/* Progress Steps */}
<div className="flex items-center gap-2 mb-6">
{[1, 2, 3].map(s => (
{[1, 2, 3, 4].map(s => (
<React.Fragment key={s}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s < step ? 'bg-green-500 text-white' :
@@ -193,7 +195,7 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
</svg>
) : s}
</div>
{s < 3 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
</React.Fragment>
))}
</div>
@@ -203,11 +205,9 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
{step === 1 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA *</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="z.B. DSFA - Mitarbeiter-Monitoring"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
@@ -215,38 +215,21 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
rows={3}
placeholder="Beschreiben Sie die geplante Datenverarbeitung..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck</label>
<input
type="text"
value={formData.processingPurpose}
onChange={(e) => setFormData(prev => ({ ...prev, processingPurpose: e.target.value }))}
placeholder="z.B. Automatisierte Bewerberauswahl"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien *</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien</label>
<div className="grid grid-cols-2 gap-2">
{DATA_CATEGORIES.map(cat => (
{['Kontaktdaten', 'Identifikationsdaten', 'Finanzdaten', 'Gesundheitsdaten', 'Standortdaten', 'Nutzungsdaten'].map(cat => (
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={formData.dataCategories.includes(cat)}
onChange={() => handleCategoryToggle(cat)}
className="w-4 h-4 text-purple-600"
/>
<input type="checkbox" className="w-4 h-4 text-purple-600" />
<span className="text-sm">{cat}</span>
</label>
))}
@@ -257,19 +240,28 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
{step === 3 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Rechtsgrundlage *</label>
<label className="block text-sm font-medium text-gray-700 mb-2">Risikobewertung</label>
<p className="text-sm text-gray-500 mb-4">Bewerten Sie die Risiken fuer die Rechte und Freiheiten der Betroffenen.</p>
<div className="space-y-2">
{LEGAL_BASES.map(basis => (
<label key={basis.value} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
<input
type="radio"
name="legalBasis"
value={basis.value}
checked={formData.legalBasis === basis.value}
onChange={(e) => setFormData(prev => ({ ...prev, legalBasis: e.target.value }))}
className="w-4 h-4 text-purple-600"
/>
<span className="text-sm font-medium">{basis.label}</span>
{['Niedrig', 'Mittel', 'Hoch', 'Kritisch'].map(level => (
<label key={level} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
<input type="radio" name="risk" className="w-4 h-4 text-purple-600" />
<span className="text-sm font-medium">{level}</span>
</label>
))}
</div>
</div>
</div>
)}
{step === 4 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzmassnahmen</label>
<div className="grid grid-cols-2 gap-2">
{['Verschluesselung', 'Pseudonymisierung', 'Zugriffskontrolle', 'Loeschkonzept', 'Schulungen', 'Menschliche Pruefung'].map(m => (
<label key={m} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
<input type="checkbox" className="w-4 h-4 text-purple-600" />
<span className="text-sm">{m}</span>
</label>
))}
</div>
@@ -287,11 +279,10 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
{step === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
<button
onClick={() => step < 3 ? setStep(step + 1) : handleSubmit()}
disabled={(step === 1 && !formData.name) || (step === 2 && formData.dataCategories.length === 0) || (step === 3 && !formData.legalBasis) || isSubmitting}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
onClick={() => step < 4 ? setStep(step + 1) : onClose()}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
{isSubmitting ? 'Wird erstellt...' : step === 3 ? 'DSFA erstellen' : 'Weiter'}
{step === 4 ? 'DSFA erstellen' : 'Weiter'}
</button>
</div>
</div>
@@ -305,115 +296,28 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
export default function DSFAPage() {
const router = useRouter()
const { state } = useSDK()
const [dsfas, setDsfas] = useState<DSFA[]>([])
const [dsfas] = useState<DSFA[]>(mockDSFAs)
const [showGenerator, setShowGenerator] = useState(false)
const [filter, setFilter] = useState<string>('all')
const [isLoading, setIsLoading] = useState(true)
const [stats, setStats] = useState({
total: 0,
draft: 0,
in_review: 0,
approved: 0,
})
// UCCA trigger info (would come from SDK state)
const [uccaTrigger, setUccaTrigger] = useState<{
assessmentId: string
triggeredRules: string[]
existingDsfaId?: string
} | null>(null)
// Handle uploaded document
const handleDocumentProcessed = useCallback((doc: UploadedDocument) => {
console.log('[DSFA Page] Document processed:', doc)
}, [])
// Load DSFAs
const loadDSFAs = useCallback(async () => {
setIsLoading(true)
try {
const [dsfaList, statsData] = await Promise.all([
listDSFAs(filter === 'all' ? undefined : filter),
getDSFAStats(),
])
setDsfas(dsfaList)
setStats({
total: statsData.total,
draft: statsData.status_stats.draft || 0,
in_review: statsData.status_stats.in_review || 0,
approved: statsData.status_stats.approved || 0,
})
} catch (error) {
console.error('Failed to load DSFAs:', error)
// Set empty state on error
setDsfas([])
} finally {
setIsLoading(false)
}
}, [filter])
useEffect(() => {
loadDSFAs()
}, [loadDSFAs])
// Check for UCCA trigger from SDK state
// TODO: Enable when UCCA integration is complete
// useEffect(() => {
// if (state?.uccaAssessment?.dsfa_recommended) {
// const assessmentId = state.uccaAssessment.id
// const triggeredRules = state.uccaAssessment.triggered_rules
// ?.filter((r: { severity: string }) => r.severity === 'BLOCK' || r.severity === 'WARN')
// ?.map((r: { code: string }) => r.code) || []
//
// // Check if DSFA already exists
// getDSFAByAssessment(assessmentId).then(existingDsfa => {
// setUccaTrigger({
// assessmentId,
// triggeredRules,
// existingDsfaId: existingDsfa?.id,
// })
// })
// }
// }, [state?.uccaAssessment])
// Handle delete
const handleDelete = async (id: string) => {
if (confirm('Moechten Sie diese DSFA wirklich loeschen?')) {
try {
await deleteDSFA(id)
await loadDSFAs()
} catch (error) {
console.error('Failed to delete DSFA:', error)
}
}
}
// Handle export
const handleExport = async (id: string) => {
try {
const blob = await exportDSFAAsJSON(id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dsfa_${id.slice(0, 8)}.json`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to export DSFA:', error)
}
}
// Handle create from assessment
const handleCreateFromAssessment = async () => {
if (!uccaTrigger?.assessmentId) return
try {
const response = await createDSFAFromAssessment(uccaTrigger.assessmentId)
router.push(`/sdk/dsfa/${response.dsfa.id}`)
} catch (error) {
console.error('Failed to create DSFA from assessment:', error)
}
}
// Open document in workflow editor
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
router.push(`/compliance/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
}, [router])
const filteredDSFAs = filter === 'all'
? dsfas
: dsfas.filter(d => d.status === filter)
const draftCount = dsfas.filter(d => d.status === 'draft').length
const inReviewCount = dsfas.filter(d => d.status === 'in-review').length
const approvedCount = dsfas.filter(d => d.status === 'approved').length
const stepInfo = STEP_EXPLANATIONS['dsfa']
return (
@@ -426,67 +330,55 @@ export default function DSFAPage() {
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<div className="flex items-center gap-2">
{!showGenerator && (
<>
<button
onClick={() => setShowGenerator(true)}
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>
Standalone DSFA
</button>
</>
)}
</div>
{!showGenerator && (
<button
onClick={() => setShowGenerator(true)}
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>
Neue DSFA
</button>
)}
</StepHeader>
{/* UCCA Trigger Warning */}
{uccaTrigger && (
<UCCATriggerWarning
assessmentId={uccaTrigger.assessmentId}
triggeredRules={uccaTrigger.triggeredRules}
existingDsfaId={uccaTrigger.existingDsfaId}
onCreateDSFA={handleCreateFromAssessment}
/>
)}
{/* Generator */}
{showGenerator && (
<GeneratorWizard
onClose={() => setShowGenerator(false)}
onCreated={(dsfa) => {
router.push(`/sdk/dsfa/${dsfa.id}`)
}}
/>
<GeneratorWizard onClose={() => setShowGenerator(false)} />
)}
{/* Document Upload Section */}
<DocumentUploadSection
documentType="dsfa"
onDocumentProcessed={handleDocumentProcessed}
onOpenInEditor={handleOpenInEditor}
/>
{/* 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">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
<div className="text-3xl font-bold text-gray-900">{dsfas.length}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Entwuerfe</div>
<div className="text-3xl font-bold text-gray-500">{stats.draft}</div>
<div className="text-3xl font-bold text-gray-500">{draftCount}</div>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-6">
<div className="text-sm text-yellow-600">In Pruefung</div>
<div className="text-3xl font-bold text-yellow-600">{stats.in_review}</div>
<div className="text-3xl font-bold text-yellow-600">{inReviewCount}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Genehmigt</div>
<div className="text-3xl font-bold text-green-600">{stats.approved}</div>
<div className="text-3xl font-bold text-green-600">{approvedCount}</div>
</div>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'draft', 'in_review', 'approved', 'needs_update'].map(f => (
{['all', 'draft', 'in-review', 'approved', 'needs-update'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
@@ -496,31 +388,22 @@ export default function DSFAPage() {
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' : DSFA_STATUS_LABELS[f as DSFAStatus] || f}
{f === 'all' ? 'Alle' :
f === 'draft' ? 'Entwuerfe' :
f === 'in-review' ? 'In Pruefung' :
f === 'approved' ? 'Genehmigt' : 'Update erforderlich'}
</button>
))}
</div>
{/* DSFA List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<div className="space-y-4">
{filteredDSFAs.map(dsfa => (
<DSFACard
key={dsfa.id}
dsfa={dsfa}
onDelete={handleDelete}
onExport={handleExport}
/>
))}
</div>
)}
<div className="space-y-4">
{filteredDSFAs.map(dsfa => (
<DSFACard key={dsfa.id} dsfa={dsfa} />
))}
</div>
{/* Empty State */}
{!isLoading && filteredDSFAs.length === 0 && !showGenerator && (
{filteredDSFAs.length === 0 && !showGenerator && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -528,19 +411,13 @@ export default function DSFAPage() {
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine DSFAs gefunden</h3>
<p className="mt-2 text-gray-500">
{filter !== 'all'
? `Keine DSFAs mit Status "${DSFA_STATUS_LABELS[filter as DSFAStatus]}".`
: 'Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.'}
</p>
{filter === 'all' && (
<button
onClick={() => setShowGenerator(true)}
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste DSFA erstellen
</button>
)}
<p className="mt-2 text-gray-500">Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.</p>
<button
onClick={() => setShowGenerator(true)}
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste DSFA erstellen
</button>
</div>
)}
</div>

View File

@@ -2,7 +2,9 @@
import React from 'react'
import Link from 'next/link'
import { useSDK, getStepsForPhase } from '@/lib/sdk'
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
import { CustomerTypeSelector } from '@/components/sdk/CustomerTypeSelector'
import type { CustomerType, SDKPackageId } from '@/lib/sdk/types'
// =============================================================================
// DASHBOARD CARDS
@@ -35,49 +37,65 @@ function StatCard({
)
}
function PhaseCard({
phase,
title,
description,
function PackageCard({
pkg,
completion,
steps,
href,
stepsCount,
isLocked,
}: {
phase: number
title: string
description: string
pkg: (typeof SDK_PACKAGES)[number]
completion: number
steps: number
href: string
stepsCount: number
isLocked: boolean
}) {
return (
<Link
href={href}
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-lg transition-all"
const steps = getStepsForPackage(pkg.id)
const firstStep = steps[0]
const href = firstStep?.url || '/sdk'
const content = (
<div
className={`block bg-white rounded-xl border-2 p-6 transition-all ${
isLocked
? 'border-gray-100 opacity-60 cursor-not-allowed'
: completion === 100
? 'border-green-200 hover:border-green-300 hover:shadow-lg'
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
}`}
>
<div className="flex items-start gap-4">
<div
className={`w-12 h-12 rounded-xl flex items-center justify-center text-xl font-bold ${
completion === 100
className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
isLocked
? 'bg-gray-100 text-gray-400'
: completion === 100
? 'bg-green-100 text-green-600'
: 'bg-purple-100 text-purple-600'
}`}
>
{completion === 100 ? (
{isLocked ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : completion === 100 ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
phase
pkg.icon
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<p className="mt-1 text-sm text-gray-500">{description}</p>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">{pkg.order}.</span>
<h3 className="text-lg font-semibold text-gray-900">{pkg.name}</h3>
</div>
<p className="mt-1 text-sm text-gray-500">{pkg.description}</p>
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-500">{steps} Schritte</span>
<span className="font-medium text-purple-600">{completion}%</span>
<span className="text-gray-500">{stepsCount} Schritte</span>
<span className={`font-medium ${completion === 100 ? 'text-green-600' : 'text-purple-600'}`}>
{completion}%
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
@@ -88,8 +106,23 @@ function PhaseCard({
/>
</div>
</div>
{!isLocked && (
<p className="mt-3 text-xs text-gray-400">
Ergebnis: {pkg.result}
</p>
)}
</div>
</div>
</div>
)
if (isLocked) {
return content
}
return (
<Link href={href}>
{content}
</Link>
)
}
@@ -129,24 +162,63 @@ function QuickActionCard({
// =============================================================================
export default function SDKDashboard() {
const { state, phase1Completion, phase2Completion, completionPercentage } = useSDK()
const { state, packageCompletion, completionPercentage, setCustomerType } = useSDK()
const phase1Steps = getStepsForPhase(1)
const phase2Steps = getStepsForPhase(2)
// Calculate total steps
const totalSteps = SDK_PACKAGES.reduce((sum, pkg) => {
const steps = getStepsForPackage(pkg.id)
// Filter import step for new customers
return sum + steps.filter(s => !(s.id === 'import' && state.customerType === 'new')).length
}, 0)
// Calculate stats
const completedCheckpoints = Object.values(state.checkpoints).filter(cp => cp.passed).length
const totalRisks = state.risks.length
const criticalRisks = state.risks.filter(r => r.severity === 'CRITICAL' || r.severity === 'HIGH').length
const isPackageLocked = (packageId: SDKPackageId): boolean => {
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
// Check if previous package is complete
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
// Show customer type selector if not set
if (!state.customerType) {
return (
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center py-12">
<CustomerTypeSelector
onSelect={(type: CustomerType) => {
setCustomerType(type)
}}
/>
</div>
)
}
return (
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
<p className="mt-1 text-gray-500">
Willkommen zum Compliance Assessment. Starten Sie mit Phase 1 oder setzen Sie Ihre Arbeit fort.
</p>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
<p className="mt-1 text-gray-500">
{state.customerType === 'new'
? 'Neukunden-Modus: Erstellen Sie alle Compliance-Dokumente von Grund auf.'
: 'Bestandskunden-Modus: Erweitern Sie bestehende Dokumente.'}
</p>
</div>
<button
onClick={() => setCustomerType(state.customerType === 'new' ? 'existing' : 'new')}
className="text-sm text-purple-600 hover:text-purple-700 underline"
>
{state.customerType === 'new' ? 'Zu Bestandskunden wechseln' : 'Zu Neukunden wechseln'}
</button>
</div>
{/* Stats Grid */}
@@ -154,7 +226,7 @@ export default function SDKDashboard() {
<StatCard
title="Gesamtfortschritt"
value={`${completionPercentage}%`}
subtitle={`${state.completedSteps.length} von ${phase1Steps.length + phase2Steps.length} Schritten`}
subtitle={`${state.completedSteps.length} von ${totalSteps} Schritten`}
icon={
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
@@ -175,7 +247,7 @@ export default function SDKDashboard() {
/>
<StatCard
title="Checkpoints"
value={`${completedCheckpoints}/${phase1Steps.length + phase2Steps.length}`}
value={`${completedCheckpoints}/${totalSteps}`}
subtitle="Validiert"
icon={
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -197,26 +269,85 @@ export default function SDKDashboard() {
/>
</div>
{/* Phases */}
{/* Bestandskunden: Gap Analysis Banner */}
{state.customerType === 'existing' && state.importedDocuments.length === 0 && (
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="text-2xl">📄</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">Bestehende Dokumente importieren</h3>
<p className="mt-1 text-gray-600">
Laden Sie Ihre vorhandenen Compliance-Dokumente hoch. Unsere KI analysiert sie und zeigt Ihnen, welche Erweiterungen fuer KI-Compliance erforderlich sind.
</p>
<Link
href="/sdk/import"
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Dokumente hochladen
</Link>
</div>
</div>
</div>
)}
{/* Gap Analysis Results */}
{state.gapAnalysis && (
<div className="bg-white border border-gray-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
<span className="text-xl">📊</span>
</div>
<div>
<h3 className="font-semibold text-gray-900">Gap-Analyse Ergebnis</h3>
<p className="text-sm text-gray-500">
{state.gapAnalysis.totalGaps} Luecken gefunden
</p>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">{state.gapAnalysis.criticalGaps}</div>
<div className="text-xs text-red-600">Kritisch</div>
</div>
<div className="text-center p-3 bg-orange-50 rounded-lg">
<div className="text-2xl font-bold text-orange-600">{state.gapAnalysis.highGaps}</div>
<div className="text-xs text-orange-600">Hoch</div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">{state.gapAnalysis.mediumGaps}</div>
<div className="text-xs text-yellow-600">Mittel</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-600">{state.gapAnalysis.lowGaps}</div>
<div className="text-xs text-green-600">Niedrig</div>
</div>
</div>
</div>
)}
{/* 5 Packages */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Phasen</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<PhaseCard
phase={1}
title="Compliance Assessment"
description="Use Case erfassen, Screening durchführen, Risiken bewerten"
completion={phase1Completion}
steps={phase1Steps.length}
href="/sdk/advisory-board"
/>
<PhaseCard
phase={2}
title="Dokumentengenerierung"
description="DSFA, TOMs, VVT, Cookie Banner und mehr generieren"
completion={phase2Completion}
steps={phase2Steps.length}
href="/sdk/ai-act"
/>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{SDK_PACKAGES.map(pkg => {
const steps = getStepsForPackage(pkg.id)
const visibleSteps = steps.filter(s => !(s.id === 'import' && state.customerType === 'new'))
return (
<PackageCard
key={pkg.id}
pkg={pkg}
completion={packageCompletion[pkg.id]}
stepsCount={visibleSteps.length}
isLocked={isPackageLocked(pkg.id)}
/>
)
})}
</div>
</div>
@@ -248,7 +379,7 @@ export default function SDKDashboard() {
/>
<QuickActionCard
title="DSFA generieren"
description="Datenschutz-Folgenabschätzung erstellen"
description="Datenschutz-Folgenabschaetzung erstellen"
icon={
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
@@ -274,7 +405,7 @@ export default function SDKDashboard() {
{/* Recent Activity */}
{state.commandBarHistory.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitäten</h2>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitaeten</h2>
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{state.commandBarHistory.slice(0, 5).map(entry => (
<div key={entry.id} className="flex items-center gap-4 px-4 py-3">

View File

@@ -9,10 +9,18 @@ import { NextRequest, NextResponse } from 'next/server'
// Checkpoint definitions
const CHECKPOINTS = {
'CP-PROF': {
id: 'CP-PROF',
step: 'company-profile',
name: 'Unternehmensprofil Checkpoint',
type: 'REQUIRED',
blocksProgress: true,
requiresReview: 'NONE',
},
'CP-UC': {
id: 'CP-UC',
step: 'use-case-workshop',
name: 'Use Case Checkpoint',
step: 'use-case-assessment',
name: 'Anwendungsfall Checkpoint',
type: 'REQUIRED',
blocksProgress: true,
requiresReview: 'NONE',

View File

@@ -0,0 +1,130 @@
/**
* DSGVO API Proxy - Catch-all route
* Proxies all /api/sdk/v1/dsgvo/* requests to ai-compliance-sdk backend
*/
import { NextRequest, NextResponse } from 'next/server'
const SDK_BACKEND_URL = process.env.SDK_API_URL || 'http://ai-compliance-sdk:8090'
async function proxyRequest(
request: NextRequest,
pathSegments: string[],
method: string
) {
const pathStr = pathSegments.join('/')
const searchParams = request.nextUrl.searchParams.toString()
const url = `${SDK_BACKEND_URL}/sdk/v1/dsgvo/${pathStr}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
// Forward auth headers if present
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
// Add body for POST/PUT/PATCH methods
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body - continue without
}
}
}
const response = await fetch(url, fetchOptions)
// Handle non-JSON responses (e.g., PDF export)
const responseContentType = response.headers.get('content-type')
if (responseContentType?.includes('application/pdf') ||
responseContentType?.includes('application/octet-stream')) {
const blob = await response.blob()
return new NextResponse(blob, {
status: response.status,
headers: {
'Content-Type': responseContentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('DSGVO API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum SDK Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -10,14 +10,15 @@ import { NextRequest, NextResponse } from 'next/server'
const SDK_STEPS = [
// Phase 1
{ id: 'use-case-workshop', phase: 1, order: 1, name: 'Use Case Workshop', url: '/sdk/advisory-board' },
{ id: 'screening', phase: 1, order: 2, name: 'System Screening', url: '/sdk/screening' },
{ id: 'modules', phase: 1, order: 3, name: 'Compliance Modules', url: '/sdk/modules' },
{ id: 'requirements', phase: 1, order: 4, name: 'Requirements', url: '/sdk/requirements' },
{ id: 'controls', phase: 1, order: 5, name: 'Controls', url: '/sdk/controls' },
{ id: 'evidence', phase: 1, order: 6, name: 'Evidence', url: '/sdk/evidence' },
{ id: 'audit-checklist', phase: 1, order: 7, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
{ id: 'risks', phase: 1, order: 8, name: 'Risk Matrix', url: '/sdk/risks' },
{ id: 'company-profile', phase: 1, order: 1, name: 'Unternehmensprofil', url: '/sdk/company-profile' },
{ id: 'use-case-assessment', phase: 1, order: 2, name: 'Anwendungsfall-Erfassung', url: '/sdk/advisory-board' },
{ id: 'screening', phase: 1, order: 3, name: 'System Screening', url: '/sdk/screening' },
{ id: 'modules', phase: 1, order: 4, name: 'Compliance Modules', url: '/sdk/modules' },
{ id: 'requirements', phase: 1, order: 5, name: 'Requirements', url: '/sdk/requirements' },
{ id: 'controls', phase: 1, order: 6, name: 'Controls', url: '/sdk/controls' },
{ id: 'evidence', phase: 1, order: 7, name: 'Evidence', url: '/sdk/evidence' },
{ id: 'audit-checklist', phase: 1, order: 8, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
{ id: 'risks', phase: 1, order: 9, name: 'Risk Matrix', url: '/sdk/risks' },
// Phase 2
{ id: 'ai-act', phase: 2, order: 1, name: 'AI Act Klassifizierung', url: '/sdk/ai-act' },
{ id: 'obligations', phase: 2, order: 2, name: 'Pflichtenübersicht', url: '/sdk/obligations' },
@@ -55,7 +56,7 @@ function getPreviousStep(currentStepId: string) {
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const currentStepId = searchParams.get('currentStep') || 'use-case-workshop'
const currentStepId = searchParams.get('currentStep') || 'company-profile'
const currentStep = SDK_STEPS.find(s => s.id === currentStepId)
const nextStep = getNextStep(currentStepId)

View File

@@ -102,11 +102,11 @@ export function CommandBar({ onClose }: CommandBarProps) {
{
id: 'action-new-usecase',
type: 'ACTION',
label: 'Neuen Use Case erstellen',
description: 'Startet den Use Case Workshop Wizard',
label: 'Neuen Anwendungsfall erstellen',
description: 'Startet die Anwendungsfall-Erfassung',
icon: icons.action,
action: () => {
goToStep('use-case-workshop')
goToStep('use-case-assessment')
onClose()
},
},

View File

@@ -165,6 +165,12 @@ function QRCodeModal({ isOpen, onClose, sessionId }: QRModalProps) {
}
})
// Force HTTP for mobile access (SSL cert is for hostname, not IP)
// This is safe because it's only used on the local network
if (baseUrl.startsWith('https://')) {
baseUrl = baseUrl.replace('https://', 'http://')
}
const uploadPath = `/upload/sdk/${sessionId}`
const fullUrl = `${baseUrl}${uploadPath}`
setUploadUrl(fullUrl)

View File

@@ -4,8 +4,7 @@
* SDK Pipeline Sidebar
*
* Floating Action Button mit Drawer zur Visualisierung der SDK-Pipeline.
* Zeigt die zwei Phasen (Compliance Assessment & Dokumentengenerierung)
* mit Fortschritt und ermöglicht schnelle Navigation.
* Zeigt die 5 Pakete mit Fortschritt und ermoeglicht schnelle Navigation.
*
* Features:
* - Desktop (xl+): Fixierte Sidebar rechts
@@ -15,7 +14,7 @@
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { useSDK, SDK_STEPS, getStepsForPhase, type SDKStep } from '@/lib/sdk'
import { useSDK, SDK_STEPS, SDK_PACKAGES, getStepsForPackage, type SDKStep, type SDKPackageId } from '@/lib/sdk'
// =============================================================================
// ICONS
@@ -27,6 +26,12 @@ const CheckIcon = () => (
</svg>
)
const LockIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)
const ArrowIcon = () => (
<svg className="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
@@ -45,31 +50,6 @@ const PipelineIcon = () => (
</svg>
)
// Step Icons als Emojis
const STEP_ICONS: Record<string, string> = {
// Phase 1
'use-case-workshop': '📋',
'screening': '🔍',
'modules': '📦',
'requirements': '📜',
'controls': '🛡️',
'evidence': '📎',
'audit-checklist': '✅',
'risks': '⚠️',
// Phase 2
'ai-act': '🤖',
'obligations': '📑',
'dsfa': '📄',
'tom': '🔒',
'einwilligungen': '✍️',
'loeschfristen': '🗑️',
'vvt': '📊',
'consent': '📝',
'cookie-banner': '🍪',
'dsr': '👤',
'escalations': '🚨',
}
// =============================================================================
// STEP ITEM
// =============================================================================
@@ -82,8 +62,6 @@ interface StepItemProps {
}
function StepItem({ step, isActive, isCompleted, onNavigate }: StepItemProps) {
const icon = STEP_ICONS[step.id] || '•'
return (
<Link
href={step.url}
@@ -96,7 +74,6 @@ function StepItem({ step, isActive, isCompleted, onNavigate }: StepItemProps) {
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
}`}
>
<span className="text-lg flex-shrink-0">{icon}</span>
<span className="flex-1 text-sm truncate">{step.nameShort}</span>
{isCompleted && !isActive && (
<span className="flex-shrink-0 w-4 h-4 bg-green-500 text-white rounded-full flex items-center justify-center">
@@ -111,78 +88,91 @@ function StepItem({ step, isActive, isCompleted, onNavigate }: StepItemProps) {
}
// =============================================================================
// PHASE SECTION
// PACKAGE SECTION
// =============================================================================
interface PhaseSectionProps {
phase: 1 | 2
title: string
interface PackageSectionProps {
pkg: (typeof SDK_PACKAGES)[number]
steps: SDKStep[]
completion: number
currentStepId: string
completedSteps: string[]
isLocked: boolean
onNavigate: () => void
isExpanded: boolean
onToggle: () => void
}
function PhaseSection({
phase,
title,
function PackageSection({
pkg,
steps,
completion,
currentStepId,
completedSteps,
isLocked,
onNavigate,
isExpanded,
onToggle,
}: PhaseSectionProps) {
}: PackageSectionProps) {
return (
<div className="space-y-2">
{/* Phase Header */}
{/* Package Header */}
<button
onClick={onToggle}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-slate-50 dark:bg-gray-800 hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors"
disabled={isLocked}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
isLocked
? 'bg-slate-100 dark:bg-gray-800 opacity-50 cursor-not-allowed'
: 'bg-slate-50 dark:bg-gray-800 hover:bg-slate-100 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-2">
<div
className={`w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${
completion === 100
className={`w-7 h-7 rounded-full flex items-center justify-center text-sm ${
isLocked
? 'bg-gray-200 text-gray-400'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-purple-600 text-white'
}`}
>
{completion === 100 ? <CheckIcon /> : phase}
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : pkg.icon}
</div>
<div className="text-left">
<div className="text-sm font-medium text-slate-700 dark:text-slate-200">{title}</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{completion}% abgeschlossen</div>
<div className={`text-sm font-medium ${isLocked ? 'text-slate-400' : 'text-slate-700 dark:text-slate-200'}`}>
{pkg.order}. {pkg.nameShort}
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{completion}%</div>
</div>
</div>
<svg
className={`w-4 h-4 text-slate-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>
{!isLocked && (
<svg
className={`w-4 h-4 text-slate-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>
)}
</button>
{/* Progress Bar */}
<div className="px-3">
<div className="h-1.5 bg-slate-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
completion === 100 ? 'bg-green-500' : 'bg-purple-600'
}`}
style={{ width: `${completion}%` }}
/>
{!isLocked && (
<div className="px-3">
<div className="h-1.5 bg-slate-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
completion === 100 ? 'bg-green-500' : 'bg-purple-600'
}`}
style={{ width: `${completion}%` }}
/>
</div>
</div>
</div>
)}
{/* Steps List */}
{isExpanded && (
{isExpanded && !isLocked && (
<div className="space-y-1 pl-2">
{steps.map(step => (
<StepItem
@@ -210,39 +200,16 @@ function PipelineFlow() {
Datenfluss
</div>
<div className="p-3 bg-slate-50 dark:bg-gray-900 rounded-lg">
<div className="flex flex-col gap-2">
{/* Phase 1 Flow */}
<div className="flex items-center justify-between text-xs">
<span className="text-purple-600 dark:text-purple-400 font-medium">Phase 1</span>
<div className="flex items-center gap-1">
<span title="Use Case">📋</span>
<ArrowIcon />
<span title="Screening">🔍</span>
<ArrowIcon />
<span title="Risiken"></span>
<ArrowIcon />
<span title="Controls">🛡</span>
<div className="flex flex-col gap-1.5">
{SDK_PACKAGES.map((pkg, idx) => (
<div key={pkg.id} className="flex items-center gap-2 text-xs">
<span className="w-5 h-5 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
{pkg.icon}
</span>
<span className="text-slate-600 dark:text-slate-400 flex-1">{pkg.nameShort}</span>
{idx < SDK_PACKAGES.length - 1 && <ArrowIcon />}
</div>
</div>
{/* Arrow Down */}
<div className="flex justify-center">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</div>
{/* Phase 2 Flow */}
<div className="flex items-center justify-between text-xs">
<span className="text-indigo-600 dark:text-indigo-400 font-medium">Phase 2</span>
<div className="flex items-center gap-1">
<span title="DSFA">📄</span>
<ArrowIcon />
<span title="TOM">🔒</span>
<ArrowIcon />
<span title="VVT">📊</span>
<ArrowIcon />
<span title="Export"></span>
</div>
</div>
))}
</div>
</div>
</div>
@@ -259,60 +226,72 @@ interface SidebarContentProps {
function SidebarContent({ onNavigate }: SidebarContentProps) {
const pathname = usePathname()
const { state, phase1Completion, phase2Completion } = useSDK()
const [expandedPhases, setExpandedPhases] = useState<Record<number, boolean>>({
1: true,
2: false,
const { state, packageCompletion } = useSDK()
const [expandedPackages, setExpandedPackages] = useState<Record<SDKPackageId, boolean>>({
'vorbereitung': true,
'analyse': false,
'dokumentation': false,
'rechtliche-texte': false,
'betrieb': false,
})
const phase1Steps = getStepsForPhase(1)
const phase2Steps = getStepsForPhase(2)
// Find current step
const currentStep = SDK_STEPS.find(s => s.url === pathname)
const currentStepId = currentStep?.id || ''
// Auto-expand current phase
// Auto-expand current package
useEffect(() => {
if (currentStep) {
setExpandedPhases(prev => ({
setExpandedPackages(prev => ({
...prev,
[currentStep.phase]: true,
[currentStep.package]: true,
}))
}
}, [currentStep])
const togglePhase = (phase: number) => {
setExpandedPhases(prev => ({ ...prev, [phase]: !prev[phase] }))
const togglePackage = (packageId: SDKPackageId) => {
setExpandedPackages(prev => ({ ...prev, [packageId]: !prev[packageId] }))
}
const isPackageLocked = (packageId: SDKPackageId): boolean => {
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
// Get visible steps based on customer type
const getVisibleSteps = (packageId: SDKPackageId): SDKStep[] => {
const steps = getStepsForPackage(packageId)
return steps.filter(step => {
if (step.id === 'import' && state.customerType === 'new') {
return false
}
return true
})
}
return (
<div className="space-y-4">
{/* Phase 1 */}
<PhaseSection
phase={1}
title="Compliance Assessment"
steps={phase1Steps}
completion={phase1Completion}
currentStepId={currentStepId}
completedSteps={state.completedSteps}
onNavigate={onNavigate}
isExpanded={expandedPhases[1]}
onToggle={() => togglePhase(1)}
/>
{/* Phase 2 */}
<PhaseSection
phase={2}
title="Dokumentengenerierung"
steps={phase2Steps}
completion={phase2Completion}
currentStepId={currentStepId}
completedSteps={state.completedSteps}
onNavigate={onNavigate}
isExpanded={expandedPhases[2]}
onToggle={() => togglePhase(2)}
/>
{/* Packages */}
{SDK_PACKAGES.map(pkg => (
<PackageSection
key={pkg.id}
pkg={pkg}
steps={getVisibleSteps(pkg.id)}
completion={packageCompletion[pkg.id]}
currentStepId={currentStepId}
completedSteps={state.completedSteps}
isLocked={isPackageLocked(pkg.id)}
onNavigate={onNavigate}
isExpanded={expandedPackages[pkg.id]}
onToggle={() => togglePackage(pkg.id)}
/>
))}
{/* Pipeline Flow */}
<PipelineFlow />

View File

@@ -3,7 +3,14 @@
import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useSDK, SDK_STEPS, getStepsForPhase, SDKPhase } from '@/lib/sdk'
import {
useSDK,
SDK_STEPS,
SDK_PACKAGES,
getStepsForPackage,
type SDKPackageId,
type SDKStep,
} from '@/lib/sdk'
// =============================================================================
// ICONS
@@ -44,80 +51,6 @@ const CollapseIcon = ({ collapsed }: { collapsed: boolean }) => (
</svg>
)
// =============================================================================
// PHASE INDICATOR
// =============================================================================
interface PhaseIndicatorProps {
phase: SDKPhase
title: string
completion: number
isActive: boolean
isExpanded: boolean
onToggle: () => void
collapsed: boolean
}
function PhaseIndicator({ phase, title, completion, isActive, isExpanded, onToggle, collapsed }: PhaseIndicatorProps) {
if (collapsed) {
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-center py-3 transition-colors ${
isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
title={`${title} (${completion}%)`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{completion === 100 ? <CheckIcon /> : phase}
</div>
</button>
)
}
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${
isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{completion === 100 ? <CheckIcon /> : phase}
</div>
<div>
<div className={`font-medium ${isActive ? 'text-purple-900' : 'text-gray-700'}`}>
{title}
</div>
<div className="text-xs text-gray-500">{completion}% abgeschlossen</div>
</div>
</div>
<ChevronDownIcon className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</button>
)
}
// =============================================================================
// PROGRESS BAR
// =============================================================================
@@ -138,12 +71,108 @@ function ProgressBar({ value, className = '' }: ProgressBarProps) {
)
}
// =============================================================================
// PACKAGE INDICATOR
// =============================================================================
interface PackageIndicatorProps {
packageId: SDKPackageId
order: number
name: string
icon: string
completion: number
isActive: boolean
isExpanded: boolean
isLocked: boolean
onToggle: () => void
collapsed: boolean
}
function PackageIndicator({
order,
name,
icon,
completion,
isActive,
isExpanded,
isLocked,
onToggle,
collapsed,
}: PackageIndicatorProps) {
if (collapsed) {
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-center py-3 transition-colors ${
isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: isLocked
? 'border-l-4 border-transparent opacity-50'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
title={`${order}. ${name} (${completion}%)`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${
isLocked
? 'bg-gray-200 text-gray-400'
: isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : icon}
</div>
</button>
)
}
return (
<button
onClick={onToggle}
disabled={isLocked}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${
isLocked
? 'opacity-50 cursor-not-allowed'
: isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${
isLocked
? 'bg-gray-200 text-gray-400'
: isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : icon}
</div>
<div>
<div className={`font-medium text-sm ${isActive ? 'text-purple-900' : isLocked ? 'text-gray-400' : 'text-gray-700'}`}>
{order}. {name}
</div>
<div className="text-xs text-gray-500">{completion}%</div>
</div>
</div>
{!isLocked && <ChevronDownIcon className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`} />}
</button>
)
}
// =============================================================================
// STEP ITEM
// =============================================================================
interface StepItemProps {
step: (typeof SDK_STEPS)[number]
step: SDKStep
isActive: boolean
isCompleted: boolean
isLocked: boolean
@@ -261,25 +290,48 @@ interface SDKSidebarProps {
export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarProps) {
const pathname = usePathname()
const { state, phase1Completion, phase2Completion, getCheckpointStatus } = useSDK()
const [expandedPhases, setExpandedPhases] = React.useState<Record<number, boolean>>({
1: true,
2: true,
const { state, packageCompletion, completionPercentage, getCheckpointStatus } = useSDK()
const [expandedPackages, setExpandedPackages] = React.useState<Record<SDKPackageId, boolean>>({
'vorbereitung': true,
'analyse': false,
'dokumentation': false,
'rechtliche-texte': false,
'betrieb': false,
})
const togglePhase = (phase: number) => {
setExpandedPhases(prev => ({ ...prev, [phase]: !prev[phase] }))
// Auto-expand current package
React.useEffect(() => {
const currentStep = SDK_STEPS.find(s => s.url === pathname)
if (currentStep) {
setExpandedPackages(prev => ({
...prev,
[currentStep.package]: true,
}))
}
}, [pathname])
const togglePackage = (packageId: SDKPackageId) => {
setExpandedPackages(prev => ({ ...prev, [packageId]: !prev[packageId] }))
}
const phase1Steps = getStepsForPhase(1)
const phase2Steps = getStepsForPhase(2)
const isStepLocked = (step: (typeof SDK_STEPS)[number]): boolean => {
const isStepLocked = (step: SDKStep): boolean => {
if (state.preferences?.allowParallelWork) return false
return step.prerequisiteSteps.some(prereq => !state.completedSteps.includes(prereq))
}
const getStepCheckpointStatus = (step: (typeof SDK_STEPS)[number]): 'passed' | 'failed' | 'warning' | 'pending' => {
const isPackageLocked = (packageId: SDKPackageId): boolean => {
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
// Check if previous package is complete
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
const getStepCheckpointStatus = (step: SDKStep): 'passed' | 'failed' | 'warning' | 'pending' => {
const status = getCheckpointStatus(step.checkpointId)
if (!status) return 'pending'
if (status.passed) return 'passed'
@@ -288,6 +340,25 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
return 'pending'
}
const isStepActive = (stepUrl: string) => pathname === stepUrl
const isPackageActive = (packageId: SDKPackageId) => {
const steps = getStepsForPackage(packageId)
return steps.some(s => s.url === pathname)
}
// Filter steps based on customer type
const getVisibleSteps = (packageId: SDKPackageId): SDKStep[] => {
const steps = getStepsForPackage(packageId)
return steps.filter(step => {
// Hide import step for new customers
if (step.id === 'import' && state.customerType === 'new') {
return false
}
return true
})
}
return (
<aside className={`fixed left-0 top-0 h-screen ${collapsed ? 'w-16' : 'w-64'} bg-white border-r border-gray-200 flex flex-col z-40 transition-all duration-300`}>
{/* Header */}
@@ -317,71 +388,51 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
<div className="px-4 py-3 border-b border-gray-100">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600">Gesamtfortschritt</span>
<span className="font-medium text-purple-600">
{Math.round((phase1Completion + phase2Completion) / 2)}%
</span>
<span className="font-medium text-purple-600">{completionPercentage}%</span>
</div>
<ProgressBar value={(phase1Completion + phase2Completion) / 2} />
<ProgressBar value={completionPercentage} />
</div>
)}
{/* Navigation */}
{/* Navigation - 5 Packages */}
<nav className="flex-1 overflow-y-auto">
{/* Phase 1 */}
<div>
<PhaseIndicator
phase={1}
title="Compliance Assessment"
completion={phase1Completion}
isActive={state.currentPhase === 1}
isExpanded={expandedPhases[1]}
onToggle={() => togglePhase(1)}
collapsed={collapsed}
/>
{expandedPhases[1] && (
<div className="py-1">
{phase1Steps.map(step => (
<StepItem
key={step.id}
step={step}
isActive={pathname === step.url}
isCompleted={state.completedSteps.includes(step.id)}
isLocked={isStepLocked(step)}
checkpointStatus={getStepCheckpointStatus(step)}
collapsed={collapsed}
/>
))}
</div>
)}
</div>
{SDK_PACKAGES.map(pkg => {
const steps = getVisibleSteps(pkg.id)
const isLocked = isPackageLocked(pkg.id)
const isActive = isPackageActive(pkg.id)
{/* Phase 2 */}
<div className="border-t border-gray-100">
<PhaseIndicator
phase={2}
title="Dokumentengenerierung"
completion={phase2Completion}
isActive={state.currentPhase === 2}
isExpanded={expandedPhases[2]}
onToggle={() => togglePhase(2)}
collapsed={collapsed}
/>
{expandedPhases[2] && (
<div className="py-1">
{phase2Steps.map(step => (
<StepItem
key={step.id}
step={step}
isActive={pathname === step.url}
isCompleted={state.completedSteps.includes(step.id)}
isLocked={isStepLocked(step)}
checkpointStatus={getStepCheckpointStatus(step)}
collapsed={collapsed}
/>
))}
return (
<div key={pkg.id} className={pkg.order > 1 ? 'border-t border-gray-100' : ''}>
<PackageIndicator
packageId={pkg.id}
order={pkg.order}
name={pkg.name}
icon={pkg.icon}
completion={packageCompletion[pkg.id]}
isActive={isActive}
isExpanded={expandedPackages[pkg.id]}
isLocked={isLocked}
onToggle={() => togglePackage(pkg.id)}
collapsed={collapsed}
/>
{expandedPackages[pkg.id] && !isLocked && (
<div className="py-1">
{steps.map(step => (
<StepItem
key={step.id}
step={step}
isActive={isStepActive(step.url)}
isCompleted={state.completedSteps.includes(step.id)}
isLocked={isStepLocked(step)}
checkpointStatus={getStepCheckpointStatus(step)}
collapsed={collapsed}
/>
))}
</div>
)}
</div>
)}
</div>
)
})}
{/* Additional Modules */}
<div className="border-t border-gray-100 py-2">

View File

@@ -11,13 +11,21 @@ import {
Risk,
Control,
UserPreferences,
CustomerType,
CompanyProfile,
ImportedDocument,
GapAnalysis,
SDKPackageId,
SDK_STEPS,
SDK_PACKAGES,
getStepById,
getStepByUrl,
getNextStep,
getPreviousStep,
getCompletionPercentage,
getPhaseCompletionPercentage,
getPackageCompletionPercentage,
getStepsForPackage,
} from './types'
import { exportToPDF, exportToZIP } from './export'
import { SDKApiClient, getSDKApiClient, resetSDKApiClient } from './api-client'
@@ -48,12 +56,22 @@ const initialState: SDKState = {
userId: '',
subscription: 'PROFESSIONAL',
// Customer Type
customerType: null,
// Company Profile
companyProfile: null,
// Progress
currentPhase: 1,
currentStep: 'use-case-workshop',
currentStep: 'company-profile',
completedSteps: [],
checkpoints: {},
// Imported Documents (for existing customers)
importedDocuments: [],
gapAnalysis: null,
// Phase 1 Data
useCases: [],
activeUseCase: null,
@@ -148,6 +166,39 @@ function sdkReducer(state: SDKState, action: ExtendedSDKAction): SDKState {
},
})
case 'SET_CUSTOMER_TYPE':
return updateState({ customerType: action.payload })
case 'SET_COMPANY_PROFILE':
return updateState({ companyProfile: action.payload })
case 'UPDATE_COMPANY_PROFILE':
return updateState({
companyProfile: state.companyProfile
? { ...state.companyProfile, ...action.payload }
: null,
})
case 'ADD_IMPORTED_DOCUMENT':
return updateState({
importedDocuments: [...state.importedDocuments, action.payload],
})
case 'UPDATE_IMPORTED_DOCUMENT':
return updateState({
importedDocuments: state.importedDocuments.map(doc =>
doc.id === action.payload.id ? { ...doc, ...action.payload.data } : doc
),
})
case 'DELETE_IMPORTED_DOCUMENT':
return updateState({
importedDocuments: state.importedDocuments.filter(doc => doc.id !== action.payload),
})
case 'SET_GAP_ANALYSIS':
return updateState({ gapAnalysis: action.payload })
case 'ADD_USE_CASE':
return updateState({
useCases: [...state.useCases, action.payload],
@@ -388,6 +439,18 @@ interface SDKContextValue {
completionPercentage: number
phase1Completion: number
phase2Completion: number
packageCompletion: Record<SDKPackageId, number>
// Customer Type
setCustomerType: (type: CustomerType) => void
// Company Profile
setCompanyProfile: (profile: CompanyProfile) => void
updateCompanyProfile: (updates: Partial<CompanyProfile>) => void
// Import (for existing customers)
addImportedDocument: (doc: ImportedDocument) => void
setGapAnalysis: (analysis: GapAnalysis) => void
// Checkpoints
validateCheckpoint: (checkpointId: string) => Promise<CheckpointStatus>
@@ -651,6 +714,42 @@ export function SDKProvider({
const phase1Completion = useMemo(() => getPhaseCompletionPercentage(state, 1), [state])
const phase2Completion = useMemo(() => getPhaseCompletionPercentage(state, 2), [state])
// Package Completion
const packageCompletion = useMemo(() => {
const completion: Record<SDKPackageId, number> = {
'vorbereitung': getPackageCompletionPercentage(state, 'vorbereitung'),
'analyse': getPackageCompletionPercentage(state, 'analyse'),
'dokumentation': getPackageCompletionPercentage(state, 'dokumentation'),
'rechtliche-texte': getPackageCompletionPercentage(state, 'rechtliche-texte'),
'betrieb': getPackageCompletionPercentage(state, 'betrieb'),
}
return completion
}, [state])
// Customer Type
const setCustomerType = useCallback((type: CustomerType) => {
dispatch({ type: 'SET_CUSTOMER_TYPE', payload: type })
}, [])
// Company Profile
const setCompanyProfile = useCallback((profile: CompanyProfile) => {
dispatch({ type: 'SET_COMPANY_PROFILE', payload: profile })
}, [])
const updateCompanyProfile = useCallback((updates: Partial<CompanyProfile>) => {
dispatch({ type: 'UPDATE_COMPANY_PROFILE', payload: updates })
}, [])
// Import Document
const addImportedDocument = useCallback((doc: ImportedDocument) => {
dispatch({ type: 'ADD_IMPORTED_DOCUMENT', payload: doc })
}, [])
// Gap Analysis
const setGapAnalysis = useCallback((analysis: GapAnalysis) => {
dispatch({ type: 'SET_GAP_ANALYSIS', payload: analysis })
}, [])
// Checkpoints
const validateCheckpoint = useCallback(
async (checkpointId: string): Promise<CheckpointStatus> => {
@@ -684,13 +783,25 @@ export function SDKProvider({
}
switch (checkpointId) {
case 'CP-PROF':
if (!state.companyProfile || !state.companyProfile.isComplete) {
status.passed = false
status.errors.push({
ruleId: 'prof-complete',
field: 'companyProfile',
message: 'Unternehmensprofil muss vollständig ausgefüllt werden',
severity: 'ERROR',
})
}
break
case 'CP-UC':
if (state.useCases.length === 0) {
status.passed = false
status.errors.push({
ruleId: 'uc-min-count',
field: 'useCases',
message: 'Mindestens ein Use Case muss erstellt werden',
message: 'Mindestens ein Anwendungsfall muss erstellt werden',
severity: 'ERROR',
})
}
@@ -925,6 +1036,12 @@ export function SDKProvider({
completionPercentage,
phase1Completion,
phase2Completion,
packageCompletion,
setCustomerType,
setCompanyProfile,
updateCompanyProfile,
addImportedDocument,
setGapAnalysis,
validateCheckpoint,
overrideCheckpoint,
getCheckpointStatus,

View File

@@ -56,11 +56,44 @@ export function generateDemoState(tenantId: string, userId: string): Partial<SDK
userId,
subscription: 'PROFESSIONAL',
// Customer Type
customerType: 'new',
// Company Profile (Demo: TechStart GmbH - SaaS-Startup aus Berlin)
companyProfile: {
companyName: 'TechStart GmbH',
legalForm: 'gmbh',
industry: 'Technologie / IT',
foundedYear: 2022,
businessModel: 'B2B_B2C',
offerings: ['app_web', 'software_saas', 'services_consulting'],
companySize: 'small',
employeeCount: '10-49',
annualRevenue: '2-10 Mio',
headquartersCountry: 'DE',
headquartersCity: 'Berlin',
hasInternationalLocations: false,
internationalCountries: [],
targetMarkets: ['germany_only', 'dach'],
primaryJurisdiction: 'DE',
isDataController: true,
isDataProcessor: true,
usesAI: true,
aiUseCases: ['KI-gestützte Kundenberatung', 'Automatisierte Dokumentenanalyse'],
dpoName: 'Max Mustermann',
dpoEmail: 'dsb@techstart.de',
legalContactName: null,
legalContactEmail: null,
isComplete: true,
completedAt: new Date('2026-01-14'),
},
// Progress - showing a realistic partially completed workflow
currentPhase: 2,
currentStep: 'tom',
completedSteps: [
'use-case-workshop',
'company-profile',
'use-case-assessment',
'screening',
'modules',
'requirements',
@@ -73,6 +106,7 @@ export function generateDemoState(tenantId: string, userId: string): Partial<SDK
'dsfa',
],
checkpoints: {
'CP-PROF': { checkpointId: 'CP-PROF', passed: true, validatedAt: new Date('2026-01-14'), validatedBy: 'demo-user', errors: [], warnings: [] },
'CP-UC': { checkpointId: 'CP-UC', passed: true, validatedAt: new Date('2026-01-15'), validatedBy: 'demo-user', errors: [], warnings: [] },
'CP-SCAN': { checkpointId: 'CP-SCAN', passed: true, validatedAt: new Date('2026-01-16'), validatedBy: 'demo-user', errors: [], warnings: [] },
'CP-MOD': { checkpointId: 'CP-MOD', passed: true, validatedAt: new Date('2026-01-17'), validatedBy: 'demo-user', errors: [], warnings: [] },

View File

@@ -559,6 +559,19 @@ export const SDK_STEPS: SDKStep[] = [
prerequisiteSteps: ['consent'],
isOptional: false,
},
{
id: 'document-generator',
phase: 2,
package: 'rechtliche-texte',
order: 4,
name: 'Dokumentengenerator',
nameShort: 'Generator',
description: 'Rechtliche Dokumente aus Vorlagen erstellen',
url: '/sdk/document-generator',
checkpointId: 'CP-DOCGEN',
prerequisiteSteps: ['cookie-banner'],
isOptional: true,
},
// =============================================================================
// PAKET 5: BETRIEB (Operations)