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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
import { DSFACard } from '@/components/sdk/dsfa'
|
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
||||||
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'
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// UCCA TRIGGER WARNING COMPONENT
|
// TYPES
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface UCCATriggerWarningProps {
|
interface DSFA {
|
||||||
assessmentId: string
|
id: string
|
||||||
triggeredRules: string[]
|
title: string
|
||||||
existingDsfaId?: string
|
description: string
|
||||||
onCreateDSFA: () => void
|
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,
|
// MOCK DATA
|
||||||
triggeredRules,
|
// =============================================================================
|
||||||
existingDsfaId,
|
|
||||||
onCreateDSFA,
|
const mockDSFAs: DSFA[] = [
|
||||||
}: UCCATriggerWarningProps) {
|
{
|
||||||
if (existingDsfaId) {
|
id: 'dsfa-1',
|
||||||
return (
|
title: 'DSFA - Bewerber-Management-System',
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex items-start gap-4">
|
description: 'Datenschutz-Folgenabschaetzung fuer das KI-gestuetzte Bewerber-Screening',
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
status: 'in-review',
|
||||||
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
createdAt: new Date('2024-01-10'),
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
updatedAt: new Date('2024-01-20'),
|
||||||
</svg>
|
approvedBy: null,
|
||||||
</div>
|
riskLevel: 'high',
|
||||||
<div className="flex-1">
|
processingActivity: 'Automatisierte Bewertung von Bewerbungsunterlagen',
|
||||||
<h4 className="font-medium text-blue-800">DSFA bereits erstellt</h4>
|
dataCategories: ['Kontaktdaten', 'Beruflicher Werdegang', 'Qualifikationen'],
|
||||||
<p className="text-sm text-blue-600 mt-1">
|
recipients: ['HR-Abteilung', 'Fachabteilungen'],
|
||||||
Fuer dieses Assessment wurde bereits eine DSFA angelegt.
|
measures: ['Verschluesselung', 'Zugriffskontrolle', 'Menschliche Pruefung'],
|
||||||
</p>
|
},
|
||||||
<Link
|
{
|
||||||
href={`/sdk/dsfa/${existingDsfaId}`}
|
id: 'dsfa-2',
|
||||||
className="inline-flex items-center gap-1 mt-2 text-sm text-blue-700 hover:text-blue-800 font-medium"
|
title: 'DSFA - Video-Ueberwachung Buero',
|
||||||
>
|
description: 'Datenschutz-Folgenabschaetzung fuer die Videoueberwachung im Buerogebaeude',
|
||||||
DSFA oeffnen
|
status: 'approved',
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
createdAt: new Date('2023-11-01'),
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
updatedAt: new Date('2023-12-15'),
|
||||||
</svg>
|
approvedBy: 'DSB Mueller',
|
||||||
</Link>
|
riskLevel: 'medium',
|
||||||
</div>
|
processingActivity: 'Videoueberwachung zu Sicherheitszwecken',
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4 flex items-start gap-4">
|
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||||
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
dsfa.status === 'needs-update' ? 'border-orange-200' :
|
||||||
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
dsfa.status === 'approved' ? 'border-green-200' : 'border-gray-200'
|
||||||
<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 className="flex items-start justify-between">
|
||||||
</div>
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<h4 className="font-medium text-orange-800">DSFA erforderlich</h4>
|
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[dsfa.status]}`}>
|
||||||
<p className="text-sm text-orange-600 mt-1">
|
{statusLabels[dsfa.status]}
|
||||||
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}
|
|
||||||
</span>
|
</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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||||
// GENERATOR WIZARD COMPONENT
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreated: (dsfa: DSFA) => void }) {
|
|
||||||
const [step, setStep] = useState(1)
|
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 (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<div className="flex items-center justify-between mb-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">
|
<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">
|
<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" />
|
<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 */}
|
{/* Progress Steps */}
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
{[1, 2, 3].map(s => (
|
{[1, 2, 3, 4].map(s => (
|
||||||
<React.Fragment key={s}>
|
<React.Fragment key={s}>
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||||
s < step ? 'bg-green-500 text-white' :
|
s < step ? 'bg-green-500 text-white' :
|
||||||
@@ -193,7 +195,7 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
|
|||||||
</svg>
|
</svg>
|
||||||
) : s}
|
) : s}
|
||||||
</div>
|
</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>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -203,11 +205,9 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
|
|||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
||||||
placeholder="z.B. DSFA - Mitarbeiter-Monitoring"
|
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"
|
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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="Beschreiben Sie die geplante Datenverarbeitung..."
|
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"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<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">
|
<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">
|
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||||
<input
|
<input type="checkbox" className="w-4 h-4 text-purple-600" />
|
||||||
type="checkbox"
|
|
||||||
checked={formData.dataCategories.includes(cat)}
|
|
||||||
onChange={() => handleCategoryToggle(cat)}
|
|
||||||
className="w-4 h-4 text-purple-600"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{cat}</span>
|
<span className="text-sm">{cat}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
@@ -257,19 +240,28 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
|
|||||||
{step === 3 && (
|
{step === 3 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<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">
|
<div className="space-y-2">
|
||||||
{LEGAL_BASES.map(basis => (
|
{['Niedrig', 'Mittel', 'Hoch', 'Kritisch'].map(level => (
|
||||||
<label key={basis.value} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
<label key={level} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||||
<input
|
<input type="radio" name="risk" className="w-4 h-4 text-purple-600" />
|
||||||
type="radio"
|
<span className="text-sm font-medium">{level}</span>
|
||||||
name="legalBasis"
|
</label>
|
||||||
value={basis.value}
|
))}
|
||||||
checked={formData.legalBasis === basis.value}
|
</div>
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, legalBasis: e.target.value }))}
|
</div>
|
||||||
className="w-4 h-4 text-purple-600"
|
</div>
|
||||||
/>
|
)}
|
||||||
<span className="text-sm font-medium">{basis.label}</span>
|
{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>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -287,11 +279,10 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
|
|||||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => step < 3 ? setStep(step + 1) : handleSubmit()}
|
onClick={() => step < 4 ? setStep(step + 1) : onClose()}
|
||||||
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{isSubmitting ? 'Wird erstellt...' : step === 3 ? 'DSFA erstellen' : 'Weiter'}
|
{step === 4 ? 'DSFA erstellen' : 'Weiter'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,115 +296,28 @@ function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreate
|
|||||||
export default function DSFAPage() {
|
export default function DSFAPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
const [dsfas, setDsfas] = useState<DSFA[]>([])
|
const [dsfas] = useState<DSFA[]>(mockDSFAs)
|
||||||
const [showGenerator, setShowGenerator] = useState(false)
|
const [showGenerator, setShowGenerator] = useState(false)
|
||||||
const [filter, setFilter] = useState<string>('all')
|
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)
|
// Handle uploaded document
|
||||||
const [uccaTrigger, setUccaTrigger] = useState<{
|
const handleDocumentProcessed = useCallback((doc: UploadedDocument) => {
|
||||||
assessmentId: string
|
console.log('[DSFA Page] Document processed:', doc)
|
||||||
triggeredRules: string[]
|
}, [])
|
||||||
existingDsfaId?: string
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
// Load DSFAs
|
// Open document in workflow editor
|
||||||
const loadDSFAs = useCallback(async () => {
|
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
|
||||||
setIsLoading(true)
|
router.push(`/compliance/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
|
||||||
try {
|
}, [router])
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredDSFAs = filter === 'all'
|
const filteredDSFAs = filter === 'all'
|
||||||
? dsfas
|
? dsfas
|
||||||
: dsfas.filter(d => d.status === filter)
|
: 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']
|
const stepInfo = STEP_EXPLANATIONS['dsfa']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -426,67 +330,55 @@ export default function DSFAPage() {
|
|||||||
explanation={stepInfo.explanation}
|
explanation={stepInfo.explanation}
|
||||||
tips={stepInfo.tips}
|
tips={stepInfo.tips}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
{!showGenerator && (
|
||||||
{!showGenerator && (
|
<button
|
||||||
<>
|
onClick={() => setShowGenerator(true)}
|
||||||
<button
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
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 className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</svg>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
Neue DSFA
|
||||||
</svg>
|
</button>
|
||||||
Standalone DSFA
|
)}
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
{/* UCCA Trigger Warning */}
|
|
||||||
{uccaTrigger && (
|
|
||||||
<UCCATriggerWarning
|
|
||||||
assessmentId={uccaTrigger.assessmentId}
|
|
||||||
triggeredRules={uccaTrigger.triggeredRules}
|
|
||||||
existingDsfaId={uccaTrigger.existingDsfaId}
|
|
||||||
onCreateDSFA={handleCreateFromAssessment}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Generator */}
|
{/* Generator */}
|
||||||
{showGenerator && (
|
{showGenerator && (
|
||||||
<GeneratorWizard
|
<GeneratorWizard onClose={() => setShowGenerator(false)} />
|
||||||
onClose={() => setShowGenerator(false)}
|
|
||||||
onCreated={(dsfa) => {
|
|
||||||
router.push(`/sdk/dsfa/${dsfa.id}`)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Document Upload Section */}
|
||||||
|
<DocumentUploadSection
|
||||||
|
documentType="dsfa"
|
||||||
|
onDocumentProcessed={handleDocumentProcessed}
|
||||||
|
onOpenInEditor={handleOpenInEditor}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<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="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<div className="text-sm text-gray-500">Gesamt</div>
|
<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>
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<div className="text-sm text-gray-500">Entwuerfe</div>
|
<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>
|
||||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
<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-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>
|
||||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||||
<div className="text-sm text-green-600">Genehmigt</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-500">Filter:</span>
|
<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
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
@@ -496,31 +388,22 @@ export default function DSFAPage() {
|
|||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DSFA List */}
|
{/* DSFA List */}
|
||||||
{isLoading ? (
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-center py-12">
|
{filteredDSFAs.map(dsfa => (
|
||||||
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin" />
|
<DSFACard key={dsfa.id} dsfa={dsfa} />
|
||||||
</div>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredDSFAs.map(dsfa => (
|
|
||||||
<DSFACard
|
|
||||||
key={dsfa.id}
|
|
||||||
dsfa={dsfa}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onExport={handleExport}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
{filteredDSFAs.length === 0 && !showGenerator && (
|
||||||
{!isLoading && filteredDSFAs.length === 0 && !showGenerator && (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
<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">
|
<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">
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Keine DSFAs gefunden</h3>
|
<h3 className="text-lg font-semibold text-gray-900">Keine DSFAs gefunden</h3>
|
||||||
<p className="mt-2 text-gray-500">
|
<p className="mt-2 text-gray-500">Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.</p>
|
||||||
{filter !== 'all'
|
<button
|
||||||
? `Keine DSFAs mit Status "${DSFA_STATUS_LABELS[filter as DSFAStatus]}".`
|
onClick={() => setShowGenerator(true)}
|
||||||
: 'Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.'}
|
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
</p>
|
>
|
||||||
{filter === 'all' && (
|
Erste DSFA erstellen
|
||||||
<button
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
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
|
// DASHBOARD CARDS
|
||||||
@@ -35,49 +37,65 @@ function StatCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PhaseCard({
|
function PackageCard({
|
||||||
phase,
|
pkg,
|
||||||
title,
|
|
||||||
description,
|
|
||||||
completion,
|
completion,
|
||||||
steps,
|
stepsCount,
|
||||||
href,
|
isLocked,
|
||||||
}: {
|
}: {
|
||||||
phase: number
|
pkg: (typeof SDK_PACKAGES)[number]
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
completion: number
|
completion: number
|
||||||
steps: number
|
stepsCount: number
|
||||||
href: string
|
isLocked: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
const steps = getStepsForPackage(pkg.id)
|
||||||
<Link
|
const firstStep = steps[0]
|
||||||
href={href}
|
const href = firstStep?.url || '/sdk'
|
||||||
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-lg transition-all"
|
|
||||||
|
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="flex items-start gap-4">
|
||||||
<div
|
<div
|
||||||
className={`w-12 h-12 rounded-xl flex items-center justify-center text-xl font-bold ${
|
className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
|
||||||
completion === 100
|
isLocked
|
||||||
|
? 'bg-gray-100 text-gray-400'
|
||||||
|
: completion === 100
|
||||||
? 'bg-green-100 text-green-600'
|
? 'bg-green-100 text-green-600'
|
||||||
: 'bg-purple-100 text-purple-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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
) : (
|
) : (
|
||||||
phase
|
pkg.icon
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
<div className="flex items-center gap-2">
|
||||||
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
<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="mt-4">
|
||||||
<div className="flex items-center justify-between text-sm mb-1">
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
<span className="text-gray-500">{steps} Schritte</span>
|
<span className="text-gray-500">{stepsCount} Schritte</span>
|
||||||
<span className="font-medium text-purple-600">{completion}%</span>
|
<span className={`font-medium ${completion === 100 ? 'text-green-600' : 'text-purple-600'}`}>
|
||||||
|
{completion}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -88,8 +106,23 @@ function PhaseCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!isLocked && (
|
||||||
|
<p className="mt-3 text-xs text-gray-400">
|
||||||
|
Ergebnis: {pkg.result}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLocked) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href}>
|
||||||
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -129,24 +162,63 @@ function QuickActionCard({
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export default function SDKDashboard() {
|
export default function SDKDashboard() {
|
||||||
const { state, phase1Completion, phase2Completion, completionPercentage } = useSDK()
|
const { state, packageCompletion, completionPercentage, setCustomerType } = useSDK()
|
||||||
|
|
||||||
const phase1Steps = getStepsForPhase(1)
|
// Calculate total steps
|
||||||
const phase2Steps = getStepsForPhase(2)
|
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
|
// Calculate stats
|
||||||
const completedCheckpoints = Object.values(state.checkpoints).filter(cp => cp.passed).length
|
const completedCheckpoints = Object.values(state.checkpoints).filter(cp => cp.passed).length
|
||||||
const totalRisks = state.risks.length
|
const totalRisks = state.risks.length
|
||||||
const criticalRisks = state.risks.filter(r => r.severity === 'CRITICAL' || r.severity === 'HIGH').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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div className="flex items-start justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
|
<div>
|
||||||
<p className="mt-1 text-gray-500">
|
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
|
||||||
Willkommen zum Compliance Assessment. Starten Sie mit Phase 1 oder setzen Sie Ihre Arbeit fort.
|
<p className="mt-1 text-gray-500">
|
||||||
</p>
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
@@ -154,7 +226,7 @@ export default function SDKDashboard() {
|
|||||||
<StatCard
|
<StatCard
|
||||||
title="Gesamtfortschritt"
|
title="Gesamtfortschritt"
|
||||||
value={`${completionPercentage}%`}
|
value={`${completionPercentage}%`}
|
||||||
subtitle={`${state.completedSteps.length} von ${phase1Steps.length + phase2Steps.length} Schritten`}
|
subtitle={`${state.completedSteps.length} von ${totalSteps} Schritten`}
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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
|
<StatCard
|
||||||
title="Checkpoints"
|
title="Checkpoints"
|
||||||
value={`${completedCheckpoints}/${phase1Steps.length + phase2Steps.length}`}
|
value={`${completedCheckpoints}/${totalSteps}`}
|
||||||
subtitle="Validiert"
|
subtitle="Validiert"
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
</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>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Phasen</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
<PhaseCard
|
{SDK_PACKAGES.map(pkg => {
|
||||||
phase={1}
|
const steps = getStepsForPackage(pkg.id)
|
||||||
title="Compliance Assessment"
|
const visibleSteps = steps.filter(s => !(s.id === 'import' && state.customerType === 'new'))
|
||||||
description="Use Case erfassen, Screening durchführen, Risiken bewerten"
|
|
||||||
completion={phase1Completion}
|
return (
|
||||||
steps={phase1Steps.length}
|
<PackageCard
|
||||||
href="/sdk/advisory-board"
|
key={pkg.id}
|
||||||
/>
|
pkg={pkg}
|
||||||
<PhaseCard
|
completion={packageCompletion[pkg.id]}
|
||||||
phase={2}
|
stepsCount={visibleSteps.length}
|
||||||
title="Dokumentengenerierung"
|
isLocked={isPackageLocked(pkg.id)}
|
||||||
description="DSFA, TOMs, VVT, Cookie Banner und mehr generieren"
|
/>
|
||||||
completion={phase2Completion}
|
)
|
||||||
steps={phase2Steps.length}
|
})}
|
||||||
href="/sdk/ai-act"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,7 +379,7 @@ export default function SDKDashboard() {
|
|||||||
/>
|
/>
|
||||||
<QuickActionCard
|
<QuickActionCard
|
||||||
title="DSFA generieren"
|
title="DSFA generieren"
|
||||||
description="Datenschutz-Folgenabschätzung erstellen"
|
description="Datenschutz-Folgenabschaetzung erstellen"
|
||||||
icon={
|
icon={
|
||||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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 */}
|
{/* Recent Activity */}
|
||||||
{state.commandBarHistory.length > 0 && (
|
{state.commandBarHistory.length > 0 && (
|
||||||
<div>
|
<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">
|
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||||
{state.commandBarHistory.slice(0, 5).map(entry => (
|
{state.commandBarHistory.slice(0, 5).map(entry => (
|
||||||
<div key={entry.id} className="flex items-center gap-4 px-4 py-3">
|
<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
|
// Checkpoint definitions
|
||||||
const CHECKPOINTS = {
|
const CHECKPOINTS = {
|
||||||
|
'CP-PROF': {
|
||||||
|
id: 'CP-PROF',
|
||||||
|
step: 'company-profile',
|
||||||
|
name: 'Unternehmensprofil Checkpoint',
|
||||||
|
type: 'REQUIRED',
|
||||||
|
blocksProgress: true,
|
||||||
|
requiresReview: 'NONE',
|
||||||
|
},
|
||||||
'CP-UC': {
|
'CP-UC': {
|
||||||
id: 'CP-UC',
|
id: 'CP-UC',
|
||||||
step: 'use-case-workshop',
|
step: 'use-case-assessment',
|
||||||
name: 'Use Case Checkpoint',
|
name: 'Anwendungsfall Checkpoint',
|
||||||
type: 'REQUIRED',
|
type: 'REQUIRED',
|
||||||
blocksProgress: true,
|
blocksProgress: true,
|
||||||
requiresReview: 'NONE',
|
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 = [
|
const SDK_STEPS = [
|
||||||
// Phase 1
|
// Phase 1
|
||||||
{ id: 'use-case-workshop', phase: 1, order: 1, name: 'Use Case Workshop', url: '/sdk/advisory-board' },
|
{ id: 'company-profile', phase: 1, order: 1, name: 'Unternehmensprofil', url: '/sdk/company-profile' },
|
||||||
{ id: 'screening', phase: 1, order: 2, name: 'System Screening', url: '/sdk/screening' },
|
{ id: 'use-case-assessment', phase: 1, order: 2, name: 'Anwendungsfall-Erfassung', url: '/sdk/advisory-board' },
|
||||||
{ id: 'modules', phase: 1, order: 3, name: 'Compliance Modules', url: '/sdk/modules' },
|
{ id: 'screening', phase: 1, order: 3, name: 'System Screening', url: '/sdk/screening' },
|
||||||
{ id: 'requirements', phase: 1, order: 4, name: 'Requirements', url: '/sdk/requirements' },
|
{ id: 'modules', phase: 1, order: 4, name: 'Compliance Modules', url: '/sdk/modules' },
|
||||||
{ id: 'controls', phase: 1, order: 5, name: 'Controls', url: '/sdk/controls' },
|
{ id: 'requirements', phase: 1, order: 5, name: 'Requirements', url: '/sdk/requirements' },
|
||||||
{ id: 'evidence', phase: 1, order: 6, name: 'Evidence', url: '/sdk/evidence' },
|
{ id: 'controls', phase: 1, order: 6, name: 'Controls', url: '/sdk/controls' },
|
||||||
{ id: 'audit-checklist', phase: 1, order: 7, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
|
{ id: 'evidence', phase: 1, order: 7, name: 'Evidence', url: '/sdk/evidence' },
|
||||||
{ id: 'risks', phase: 1, order: 8, name: 'Risk Matrix', url: '/sdk/risks' },
|
{ 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
|
// Phase 2
|
||||||
{ id: 'ai-act', phase: 2, order: 1, name: 'AI Act Klassifizierung', url: '/sdk/ai-act' },
|
{ 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' },
|
{ 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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
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 currentStep = SDK_STEPS.find(s => s.id === currentStepId)
|
||||||
const nextStep = getNextStep(currentStepId)
|
const nextStep = getNextStep(currentStepId)
|
||||||
|
|||||||
@@ -102,11 +102,11 @@ export function CommandBar({ onClose }: CommandBarProps) {
|
|||||||
{
|
{
|
||||||
id: 'action-new-usecase',
|
id: 'action-new-usecase',
|
||||||
type: 'ACTION',
|
type: 'ACTION',
|
||||||
label: 'Neuen Use Case erstellen',
|
label: 'Neuen Anwendungsfall erstellen',
|
||||||
description: 'Startet den Use Case Workshop Wizard',
|
description: 'Startet die Anwendungsfall-Erfassung',
|
||||||
icon: icons.action,
|
icon: icons.action,
|
||||||
action: () => {
|
action: () => {
|
||||||
goToStep('use-case-workshop')
|
goToStep('use-case-assessment')
|
||||||
onClose()
|
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 uploadPath = `/upload/sdk/${sessionId}`
|
||||||
const fullUrl = `${baseUrl}${uploadPath}`
|
const fullUrl = `${baseUrl}${uploadPath}`
|
||||||
setUploadUrl(fullUrl)
|
setUploadUrl(fullUrl)
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
* SDK Pipeline Sidebar
|
* SDK Pipeline Sidebar
|
||||||
*
|
*
|
||||||
* Floating Action Button mit Drawer zur Visualisierung der SDK-Pipeline.
|
* Floating Action Button mit Drawer zur Visualisierung der SDK-Pipeline.
|
||||||
* Zeigt die zwei Phasen (Compliance Assessment & Dokumentengenerierung)
|
* Zeigt die 5 Pakete mit Fortschritt und ermoeglicht schnelle Navigation.
|
||||||
* mit Fortschritt und ermöglicht schnelle Navigation.
|
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Desktop (xl+): Fixierte Sidebar rechts
|
* - Desktop (xl+): Fixierte Sidebar rechts
|
||||||
@@ -15,7 +14,7 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { usePathname } from 'next/navigation'
|
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
|
// ICONS
|
||||||
@@ -27,6 +26,12 @@ const CheckIcon = () => (
|
|||||||
</svg>
|
</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 = () => (
|
const ArrowIcon = () => (
|
||||||
<svg className="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
@@ -45,31 +50,6 @@ const PipelineIcon = () => (
|
|||||||
</svg>
|
</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
|
// STEP ITEM
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -82,8 +62,6 @@ interface StepItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StepItem({ step, isActive, isCompleted, onNavigate }: StepItemProps) {
|
function StepItem({ step, isActive, isCompleted, onNavigate }: StepItemProps) {
|
||||||
const icon = STEP_ICONS[step.id] || '•'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={step.url}
|
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'
|
: '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>
|
<span className="flex-1 text-sm truncate">{step.nameShort}</span>
|
||||||
{isCompleted && !isActive && (
|
{isCompleted && !isActive && (
|
||||||
<span className="flex-shrink-0 w-4 h-4 bg-green-500 text-white rounded-full flex items-center justify-center">
|
<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 {
|
interface PackageSectionProps {
|
||||||
phase: 1 | 2
|
pkg: (typeof SDK_PACKAGES)[number]
|
||||||
title: string
|
|
||||||
steps: SDKStep[]
|
steps: SDKStep[]
|
||||||
completion: number
|
completion: number
|
||||||
currentStepId: string
|
currentStepId: string
|
||||||
completedSteps: string[]
|
completedSteps: string[]
|
||||||
|
isLocked: boolean
|
||||||
onNavigate: () => void
|
onNavigate: () => void
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function PhaseSection({
|
function PackageSection({
|
||||||
phase,
|
pkg,
|
||||||
title,
|
|
||||||
steps,
|
steps,
|
||||||
completion,
|
completion,
|
||||||
currentStepId,
|
currentStepId,
|
||||||
completedSteps,
|
completedSteps,
|
||||||
|
isLocked,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: PhaseSectionProps) {
|
}: PackageSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Phase Header */}
|
{/* Package Header */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
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="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`w-7 h-7 rounded-full flex items-center justify-center text-sm font-bold ${
|
className={`w-7 h-7 rounded-full flex items-center justify-center text-sm ${
|
||||||
completion === 100
|
isLocked
|
||||||
|
? 'bg-gray-200 text-gray-400'
|
||||||
|
: completion === 100
|
||||||
? 'bg-green-500 text-white'
|
? 'bg-green-500 text-white'
|
||||||
: 'bg-purple-600 text-white'
|
: 'bg-purple-600 text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{completion === 100 ? <CheckIcon /> : phase}
|
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : pkg.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-sm font-medium text-slate-700 dark:text-slate-200">{title}</div>
|
<div className={`text-sm font-medium ${isLocked ? 'text-slate-400' : 'text-slate-700 dark:text-slate-200'}`}>
|
||||||
<div className="text-xs text-slate-500 dark:text-slate-400">{completion}% abgeschlossen</div>
|
{pkg.order}. {pkg.nameShort}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">{completion}%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<svg
|
{!isLocked && (
|
||||||
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
<svg
|
||||||
fill="none"
|
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
stroke="currentColor"
|
||||||
>
|
viewBox="0 0 24 24"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
>
|
||||||
</svg>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="px-3">
|
{!isLocked && (
|
||||||
<div className="h-1.5 bg-slate-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
<div className="px-3">
|
||||||
<div
|
<div className="h-1.5 bg-slate-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
className={`h-full rounded-full transition-all duration-500 ${
|
<div
|
||||||
completion === 100 ? 'bg-green-500' : 'bg-purple-600'
|
className={`h-full rounded-full transition-all duration-500 ${
|
||||||
}`}
|
completion === 100 ? 'bg-green-500' : 'bg-purple-600'
|
||||||
style={{ width: `${completion}%` }}
|
}`}
|
||||||
/>
|
style={{ width: `${completion}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Steps List */}
|
{/* Steps List */}
|
||||||
{isExpanded && (
|
{isExpanded && !isLocked && (
|
||||||
<div className="space-y-1 pl-2">
|
<div className="space-y-1 pl-2">
|
||||||
{steps.map(step => (
|
{steps.map(step => (
|
||||||
<StepItem
|
<StepItem
|
||||||
@@ -210,39 +200,16 @@ function PipelineFlow() {
|
|||||||
Datenfluss
|
Datenfluss
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-slate-50 dark:bg-gray-900 rounded-lg">
|
<div className="p-3 bg-slate-50 dark:bg-gray-900 rounded-lg">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
{/* Phase 1 Flow */}
|
{SDK_PACKAGES.map((pkg, idx) => (
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div key={pkg.id} className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-purple-600 dark:text-purple-400 font-medium">Phase 1</span>
|
<span className="w-5 h-5 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-1">
|
{pkg.icon}
|
||||||
<span title="Use Case">📋</span>
|
</span>
|
||||||
<ArrowIcon />
|
<span className="text-slate-600 dark:text-slate-400 flex-1">{pkg.nameShort}</span>
|
||||||
<span title="Screening">🔍</span>
|
{idx < SDK_PACKAGES.length - 1 && <ArrowIcon />}
|
||||||
<ArrowIcon />
|
|
||||||
<span title="Risiken">⚠️</span>
|
|
||||||
<ArrowIcon />
|
|
||||||
<span title="Controls">🛡️</span>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,60 +226,72 @@ interface SidebarContentProps {
|
|||||||
|
|
||||||
function SidebarContent({ onNavigate }: SidebarContentProps) {
|
function SidebarContent({ onNavigate }: SidebarContentProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { state, phase1Completion, phase2Completion } = useSDK()
|
const { state, packageCompletion } = useSDK()
|
||||||
const [expandedPhases, setExpandedPhases] = useState<Record<number, boolean>>({
|
const [expandedPackages, setExpandedPackages] = useState<Record<SDKPackageId, boolean>>({
|
||||||
1: true,
|
'vorbereitung': true,
|
||||||
2: false,
|
'analyse': false,
|
||||||
|
'dokumentation': false,
|
||||||
|
'rechtliche-texte': false,
|
||||||
|
'betrieb': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const phase1Steps = getStepsForPhase(1)
|
|
||||||
const phase2Steps = getStepsForPhase(2)
|
|
||||||
|
|
||||||
// Find current step
|
// Find current step
|
||||||
const currentStep = SDK_STEPS.find(s => s.url === pathname)
|
const currentStep = SDK_STEPS.find(s => s.url === pathname)
|
||||||
const currentStepId = currentStep?.id || ''
|
const currentStepId = currentStep?.id || ''
|
||||||
|
|
||||||
// Auto-expand current phase
|
// Auto-expand current package
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep) {
|
if (currentStep) {
|
||||||
setExpandedPhases(prev => ({
|
setExpandedPackages(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[currentStep.phase]: true,
|
[currentStep.package]: true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}, [currentStep])
|
}, [currentStep])
|
||||||
|
|
||||||
const togglePhase = (phase: number) => {
|
const togglePackage = (packageId: SDKPackageId) => {
|
||||||
setExpandedPhases(prev => ({ ...prev, [phase]: !prev[phase] }))
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Phase 1 */}
|
{/* Packages */}
|
||||||
<PhaseSection
|
{SDK_PACKAGES.map(pkg => (
|
||||||
phase={1}
|
<PackageSection
|
||||||
title="Compliance Assessment"
|
key={pkg.id}
|
||||||
steps={phase1Steps}
|
pkg={pkg}
|
||||||
completion={phase1Completion}
|
steps={getVisibleSteps(pkg.id)}
|
||||||
currentStepId={currentStepId}
|
completion={packageCompletion[pkg.id]}
|
||||||
completedSteps={state.completedSteps}
|
currentStepId={currentStepId}
|
||||||
onNavigate={onNavigate}
|
completedSteps={state.completedSteps}
|
||||||
isExpanded={expandedPhases[1]}
|
isLocked={isPackageLocked(pkg.id)}
|
||||||
onToggle={() => togglePhase(1)}
|
onNavigate={onNavigate}
|
||||||
/>
|
isExpanded={expandedPackages[pkg.id]}
|
||||||
|
onToggle={() => togglePackage(pkg.id)}
|
||||||
{/* Phase 2 */}
|
/>
|
||||||
<PhaseSection
|
))}
|
||||||
phase={2}
|
|
||||||
title="Dokumentengenerierung"
|
|
||||||
steps={phase2Steps}
|
|
||||||
completion={phase2Completion}
|
|
||||||
currentStepId={currentStepId}
|
|
||||||
completedSteps={state.completedSteps}
|
|
||||||
onNavigate={onNavigate}
|
|
||||||
isExpanded={expandedPhases[2]}
|
|
||||||
onToggle={() => togglePhase(2)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Pipeline Flow */}
|
{/* Pipeline Flow */}
|
||||||
<PipelineFlow />
|
<PipelineFlow />
|
||||||
|
|||||||
@@ -3,7 +3,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
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
|
// ICONS
|
||||||
@@ -44,80 +51,6 @@ const CollapseIcon = ({ collapsed }: { collapsed: boolean }) => (
|
|||||||
</svg>
|
</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
|
// 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
|
// STEP ITEM
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface StepItemProps {
|
interface StepItemProps {
|
||||||
step: (typeof SDK_STEPS)[number]
|
step: SDKStep
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
isCompleted: boolean
|
isCompleted: boolean
|
||||||
isLocked: boolean
|
isLocked: boolean
|
||||||
@@ -261,25 +290,48 @@ interface SDKSidebarProps {
|
|||||||
|
|
||||||
export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarProps) {
|
export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { state, phase1Completion, phase2Completion, getCheckpointStatus } = useSDK()
|
const { state, packageCompletion, completionPercentage, getCheckpointStatus } = useSDK()
|
||||||
const [expandedPhases, setExpandedPhases] = React.useState<Record<number, boolean>>({
|
const [expandedPackages, setExpandedPackages] = React.useState<Record<SDKPackageId, boolean>>({
|
||||||
1: true,
|
'vorbereitung': true,
|
||||||
2: true,
|
'analyse': false,
|
||||||
|
'dokumentation': false,
|
||||||
|
'rechtliche-texte': false,
|
||||||
|
'betrieb': false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const togglePhase = (phase: number) => {
|
// Auto-expand current package
|
||||||
setExpandedPhases(prev => ({ ...prev, [phase]: !prev[phase] }))
|
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 isStepLocked = (step: SDKStep): boolean => {
|
||||||
const phase2Steps = getStepsForPhase(2)
|
|
||||||
|
|
||||||
const isStepLocked = (step: (typeof SDK_STEPS)[number]): boolean => {
|
|
||||||
if (state.preferences?.allowParallelWork) return false
|
if (state.preferences?.allowParallelWork) return false
|
||||||
return step.prerequisiteSteps.some(prereq => !state.completedSteps.includes(prereq))
|
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)
|
const status = getCheckpointStatus(step.checkpointId)
|
||||||
if (!status) return 'pending'
|
if (!status) return 'pending'
|
||||||
if (status.passed) return 'passed'
|
if (status.passed) return 'passed'
|
||||||
@@ -288,6 +340,25 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
|||||||
return 'pending'
|
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 (
|
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`}>
|
<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 */}
|
{/* 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="px-4 py-3 border-b border-gray-100">
|
||||||
<div className="flex items-center justify-between text-sm mb-2">
|
<div className="flex items-center justify-between text-sm mb-2">
|
||||||
<span className="text-gray-600">Gesamtfortschritt</span>
|
<span className="text-gray-600">Gesamtfortschritt</span>
|
||||||
<span className="font-medium text-purple-600">
|
<span className="font-medium text-purple-600">{completionPercentage}%</span>
|
||||||
{Math.round((phase1Completion + phase2Completion) / 2)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar value={(phase1Completion + phase2Completion) / 2} />
|
<ProgressBar value={completionPercentage} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation - 5 Packages */}
|
||||||
<nav className="flex-1 overflow-y-auto">
|
<nav className="flex-1 overflow-y-auto">
|
||||||
{/* Phase 1 */}
|
{SDK_PACKAGES.map(pkg => {
|
||||||
<div>
|
const steps = getVisibleSteps(pkg.id)
|
||||||
<PhaseIndicator
|
const isLocked = isPackageLocked(pkg.id)
|
||||||
phase={1}
|
const isActive = isPackageActive(pkg.id)
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Phase 2 */}
|
return (
|
||||||
<div className="border-t border-gray-100">
|
<div key={pkg.id} className={pkg.order > 1 ? 'border-t border-gray-100' : ''}>
|
||||||
<PhaseIndicator
|
<PackageIndicator
|
||||||
phase={2}
|
packageId={pkg.id}
|
||||||
title="Dokumentengenerierung"
|
order={pkg.order}
|
||||||
completion={phase2Completion}
|
name={pkg.name}
|
||||||
isActive={state.currentPhase === 2}
|
icon={pkg.icon}
|
||||||
isExpanded={expandedPhases[2]}
|
completion={packageCompletion[pkg.id]}
|
||||||
onToggle={() => togglePhase(2)}
|
isActive={isActive}
|
||||||
collapsed={collapsed}
|
isExpanded={expandedPackages[pkg.id]}
|
||||||
/>
|
isLocked={isLocked}
|
||||||
{expandedPhases[2] && (
|
onToggle={() => togglePackage(pkg.id)}
|
||||||
<div className="py-1">
|
collapsed={collapsed}
|
||||||
{phase2Steps.map(step => (
|
/>
|
||||||
<StepItem
|
{expandedPackages[pkg.id] && !isLocked && (
|
||||||
key={step.id}
|
<div className="py-1">
|
||||||
step={step}
|
{steps.map(step => (
|
||||||
isActive={pathname === step.url}
|
<StepItem
|
||||||
isCompleted={state.completedSteps.includes(step.id)}
|
key={step.id}
|
||||||
isLocked={isStepLocked(step)}
|
step={step}
|
||||||
checkpointStatus={getStepCheckpointStatus(step)}
|
isActive={isStepActive(step.url)}
|
||||||
collapsed={collapsed}
|
isCompleted={state.completedSteps.includes(step.id)}
|
||||||
/>
|
isLocked={isStepLocked(step)}
|
||||||
))}
|
checkpointStatus={getStepCheckpointStatus(step)}
|
||||||
|
collapsed={collapsed}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)
|
||||||
</div>
|
})}
|
||||||
|
|
||||||
{/* Additional Modules */}
|
{/* Additional Modules */}
|
||||||
<div className="border-t border-gray-100 py-2">
|
<div className="border-t border-gray-100 py-2">
|
||||||
|
|||||||
@@ -11,13 +11,21 @@ import {
|
|||||||
Risk,
|
Risk,
|
||||||
Control,
|
Control,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
|
CustomerType,
|
||||||
|
CompanyProfile,
|
||||||
|
ImportedDocument,
|
||||||
|
GapAnalysis,
|
||||||
|
SDKPackageId,
|
||||||
SDK_STEPS,
|
SDK_STEPS,
|
||||||
|
SDK_PACKAGES,
|
||||||
getStepById,
|
getStepById,
|
||||||
getStepByUrl,
|
getStepByUrl,
|
||||||
getNextStep,
|
getNextStep,
|
||||||
getPreviousStep,
|
getPreviousStep,
|
||||||
getCompletionPercentage,
|
getCompletionPercentage,
|
||||||
getPhaseCompletionPercentage,
|
getPhaseCompletionPercentage,
|
||||||
|
getPackageCompletionPercentage,
|
||||||
|
getStepsForPackage,
|
||||||
} from './types'
|
} from './types'
|
||||||
import { exportToPDF, exportToZIP } from './export'
|
import { exportToPDF, exportToZIP } from './export'
|
||||||
import { SDKApiClient, getSDKApiClient, resetSDKApiClient } from './api-client'
|
import { SDKApiClient, getSDKApiClient, resetSDKApiClient } from './api-client'
|
||||||
@@ -48,12 +56,22 @@ const initialState: SDKState = {
|
|||||||
userId: '',
|
userId: '',
|
||||||
subscription: 'PROFESSIONAL',
|
subscription: 'PROFESSIONAL',
|
||||||
|
|
||||||
|
// Customer Type
|
||||||
|
customerType: null,
|
||||||
|
|
||||||
|
// Company Profile
|
||||||
|
companyProfile: null,
|
||||||
|
|
||||||
// Progress
|
// Progress
|
||||||
currentPhase: 1,
|
currentPhase: 1,
|
||||||
currentStep: 'use-case-workshop',
|
currentStep: 'company-profile',
|
||||||
completedSteps: [],
|
completedSteps: [],
|
||||||
checkpoints: {},
|
checkpoints: {},
|
||||||
|
|
||||||
|
// Imported Documents (for existing customers)
|
||||||
|
importedDocuments: [],
|
||||||
|
gapAnalysis: null,
|
||||||
|
|
||||||
// Phase 1 Data
|
// Phase 1 Data
|
||||||
useCases: [],
|
useCases: [],
|
||||||
activeUseCase: null,
|
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':
|
case 'ADD_USE_CASE':
|
||||||
return updateState({
|
return updateState({
|
||||||
useCases: [...state.useCases, action.payload],
|
useCases: [...state.useCases, action.payload],
|
||||||
@@ -388,6 +439,18 @@ interface SDKContextValue {
|
|||||||
completionPercentage: number
|
completionPercentage: number
|
||||||
phase1Completion: number
|
phase1Completion: number
|
||||||
phase2Completion: 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
|
// Checkpoints
|
||||||
validateCheckpoint: (checkpointId: string) => Promise<CheckpointStatus>
|
validateCheckpoint: (checkpointId: string) => Promise<CheckpointStatus>
|
||||||
@@ -651,6 +714,42 @@ export function SDKProvider({
|
|||||||
const phase1Completion = useMemo(() => getPhaseCompletionPercentage(state, 1), [state])
|
const phase1Completion = useMemo(() => getPhaseCompletionPercentage(state, 1), [state])
|
||||||
const phase2Completion = useMemo(() => getPhaseCompletionPercentage(state, 2), [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
|
// Checkpoints
|
||||||
const validateCheckpoint = useCallback(
|
const validateCheckpoint = useCallback(
|
||||||
async (checkpointId: string): Promise<CheckpointStatus> => {
|
async (checkpointId: string): Promise<CheckpointStatus> => {
|
||||||
@@ -684,13 +783,25 @@ export function SDKProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (checkpointId) {
|
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':
|
case 'CP-UC':
|
||||||
if (state.useCases.length === 0) {
|
if (state.useCases.length === 0) {
|
||||||
status.passed = false
|
status.passed = false
|
||||||
status.errors.push({
|
status.errors.push({
|
||||||
ruleId: 'uc-min-count',
|
ruleId: 'uc-min-count',
|
||||||
field: 'useCases',
|
field: 'useCases',
|
||||||
message: 'Mindestens ein Use Case muss erstellt werden',
|
message: 'Mindestens ein Anwendungsfall muss erstellt werden',
|
||||||
severity: 'ERROR',
|
severity: 'ERROR',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -925,6 +1036,12 @@ export function SDKProvider({
|
|||||||
completionPercentage,
|
completionPercentage,
|
||||||
phase1Completion,
|
phase1Completion,
|
||||||
phase2Completion,
|
phase2Completion,
|
||||||
|
packageCompletion,
|
||||||
|
setCustomerType,
|
||||||
|
setCompanyProfile,
|
||||||
|
updateCompanyProfile,
|
||||||
|
addImportedDocument,
|
||||||
|
setGapAnalysis,
|
||||||
validateCheckpoint,
|
validateCheckpoint,
|
||||||
overrideCheckpoint,
|
overrideCheckpoint,
|
||||||
getCheckpointStatus,
|
getCheckpointStatus,
|
||||||
|
|||||||
@@ -56,11 +56,44 @@ export function generateDemoState(tenantId: string, userId: string): Partial<SDK
|
|||||||
userId,
|
userId,
|
||||||
subscription: 'PROFESSIONAL',
|
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
|
// Progress - showing a realistic partially completed workflow
|
||||||
currentPhase: 2,
|
currentPhase: 2,
|
||||||
currentStep: 'tom',
|
currentStep: 'tom',
|
||||||
completedSteps: [
|
completedSteps: [
|
||||||
'use-case-workshop',
|
'company-profile',
|
||||||
|
'use-case-assessment',
|
||||||
'screening',
|
'screening',
|
||||||
'modules',
|
'modules',
|
||||||
'requirements',
|
'requirements',
|
||||||
@@ -73,6 +106,7 @@ export function generateDemoState(tenantId: string, userId: string): Partial<SDK
|
|||||||
'dsfa',
|
'dsfa',
|
||||||
],
|
],
|
||||||
checkpoints: {
|
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-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-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: [] },
|
'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'],
|
prerequisiteSteps: ['consent'],
|
||||||
isOptional: false,
|
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)
|
// PAKET 5: BETRIEB (Operations)
|
||||||
|
|||||||
Reference in New Issue
Block a user