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:
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Document Generator Components
|
||||
*
|
||||
* Diese Komponenten integrieren die Einwilligungen-Datenpunkte
|
||||
* in den Dokumentengenerator.
|
||||
*/
|
||||
|
||||
export { DataPointsPreview } from './DataPointsPreview'
|
||||
export { DocumentValidation } from './DocumentValidation'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
130
admin-v2/app/api/sdk/v1/dsgvo/[...path]/route.ts
Normal file
130
admin-v2/app/api/sdk/v1/dsgvo/[...path]/route.ts
Normal 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')
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user