Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 35s
CI / test-python-backend-compliance (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 21s
Nach Abschluss von Profil + Scope werden jetzt automatisch die anwendbaren Regulierungen (DSGVO, NIS2, AI Act, DORA) ermittelt und die zustaendigen Aufsichtsbehoerden (Landes-DSB, BSI, BaFin) aus Bundesland + Branche abgeleitet. - Neues scope-to-facts.ts: Mapping CompanyProfile+Scope → Go SDK Payload - Neues supervisory-authority-resolver.ts: 16 Landes-DSB + nationale Behoerden - ScopeDecisionTab: Regulierungs-Report mit Aufsichtsbehoerden-Karten - Obligations-Seite: Echte Daten statt Dummy in handleAutoProfiling() - Neue Types: ApplicableRegulation, RegulationAssessmentResult, SupervisoryAuthorityInfo Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
454 lines
17 KiB
TypeScript
454 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
|
import { useSDK } from '@/lib/sdk'
|
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader/StepHeader'
|
|
import {
|
|
ScopeOverviewTab,
|
|
ScopeWizardTab,
|
|
ScopeDecisionTab,
|
|
ScopeExportTab
|
|
} from '@/components/sdk/compliance-scope'
|
|
import type {
|
|
ComplianceScopeState,
|
|
ScopeProfilingAnswer,
|
|
ScopeDecision,
|
|
ApplicableRegulation,
|
|
SupervisoryAuthorityInfo
|
|
} from '@/lib/sdk/compliance-scope-types'
|
|
import {
|
|
createEmptyScopeState,
|
|
STORAGE_KEY
|
|
} from '@/lib/sdk/compliance-scope-types'
|
|
import { complianceScopeEngine } from '@/lib/sdk/compliance-scope-engine'
|
|
import { getAllQuestions } from '@/lib/sdk/compliance-scope-profiling'
|
|
import { buildAssessmentPayload } from '@/lib/sdk/scope-to-facts'
|
|
import { resolveAuthorities } from '@/lib/sdk/supervisory-authority-resolver'
|
|
|
|
type TabId = 'overview' | 'wizard' | 'decision' | 'export'
|
|
|
|
const TABS: { id: TabId; label: string; icon: string }[] = [
|
|
{ id: 'overview', label: 'Uebersicht', icon: '📊' },
|
|
{ id: 'wizard', label: 'Scope-Profiling', icon: '📋' },
|
|
{ id: 'decision', label: 'Scope-Entscheidung', icon: '⚖️' },
|
|
{ id: 'export', label: 'Export', icon: '📤' },
|
|
]
|
|
|
|
export default function ComplianceScopePage() {
|
|
const { state: sdkState, dispatch } = useSDK()
|
|
|
|
// Active tab state
|
|
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
|
|
|
// Local scope state
|
|
const [scopeState, setScopeState] = useState<ComplianceScopeState>(() => {
|
|
// Try to load from SDK context first
|
|
if (sdkState.complianceScope) {
|
|
return sdkState.complianceScope
|
|
}
|
|
return createEmptyScopeState()
|
|
})
|
|
|
|
// Loading state
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isEvaluating, setIsEvaluating] = useState(false)
|
|
|
|
// Regulation assessment state
|
|
const [applicableRegulations, setApplicableRegulations] = useState<ApplicableRegulation[]>([])
|
|
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
|
|
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
|
|
|
|
// Load from SDK context first (persisted via State API), then localStorage as fallback.
|
|
// Runs ONCE on mount only — empty deps breaks the dispatch→sdkState→setScopeState→dispatch loop.
|
|
useEffect(() => {
|
|
try {
|
|
// Priority 1: SDK context (loaded from PostgreSQL via State API)
|
|
const ctxScope = sdkState.complianceScope
|
|
if (ctxScope && ctxScope.answers?.length > 0) {
|
|
setScopeState(ctxScope)
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(ctxScope))
|
|
} else {
|
|
// Priority 2: localStorage fallback
|
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored) as ComplianceScopeState
|
|
setScopeState(parsed)
|
|
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed })
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load compliance scope state:', error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// Fetch regulation assessment if decision exists on mount
|
|
useEffect(() => {
|
|
if (!isLoading && scopeState.decision && applicableRegulations.length === 0 && sdkState.companyProfile) {
|
|
fetchRegulationAssessment(scopeState.decision)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isLoading])
|
|
|
|
// Save to localStorage and SDK context whenever state changes
|
|
useEffect(() => {
|
|
if (!isLoading) {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(scopeState))
|
|
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: scopeState })
|
|
} catch (error) {
|
|
console.error('Failed to save compliance scope state:', error)
|
|
}
|
|
}
|
|
}, [scopeState, isLoading, dispatch])
|
|
|
|
// Handle answers change from wizard
|
|
const handleAnswersChange = useCallback((answers: ScopeProfilingAnswer[]) => {
|
|
setScopeState(prev => ({
|
|
...prev,
|
|
answers,
|
|
lastModified: new Date().toISOString(),
|
|
}))
|
|
}, [])
|
|
|
|
// Fetch regulation assessment from Go AI SDK
|
|
const fetchRegulationAssessment = useCallback(async (decision: ScopeDecision) => {
|
|
const profile = sdkState.companyProfile
|
|
if (!profile) return
|
|
|
|
setRegulationAssessmentLoading(true)
|
|
try {
|
|
const payload = buildAssessmentPayload(profile, scopeState.answers, decision)
|
|
const res = await fetch('/api/sdk/v1/ucca/obligations/assess-from-scope', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
const data = await res.json()
|
|
|
|
// Set applicable regulations from response
|
|
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
|
setApplicableRegulations(regs)
|
|
|
|
// Derive supervisory authorities
|
|
const regIds = regs.map(r => r.id)
|
|
const authorities = resolveAuthorities(
|
|
profile.headquartersState,
|
|
profile.headquartersCountry || 'DE',
|
|
regIds
|
|
)
|
|
setSupervisoryAuthorities(authorities)
|
|
} catch (error) {
|
|
console.error('Failed to fetch regulation assessment:', error)
|
|
} finally {
|
|
setRegulationAssessmentLoading(false)
|
|
}
|
|
}, [sdkState.companyProfile, scopeState.answers])
|
|
|
|
// Handle evaluate button click
|
|
const handleEvaluate = useCallback(async () => {
|
|
setIsEvaluating(true)
|
|
try {
|
|
// Run the compliance scope engine
|
|
const decision = complianceScopeEngine.evaluate(scopeState.answers)
|
|
|
|
// Update state with decision
|
|
setScopeState(prev => ({
|
|
...prev,
|
|
decision,
|
|
lastModified: new Date().toISOString(),
|
|
}))
|
|
|
|
// Switch to decision tab to show results
|
|
setActiveTab('decision')
|
|
|
|
// Fetch regulation assessment in the background
|
|
fetchRegulationAssessment(decision)
|
|
} catch (error) {
|
|
console.error('Failed to evaluate compliance scope:', error)
|
|
} finally {
|
|
setIsEvaluating(false)
|
|
}
|
|
}, [scopeState.answers, fetchRegulationAssessment])
|
|
|
|
// Handle start profiling from overview
|
|
const handleStartProfiling = useCallback(() => {
|
|
setActiveTab('wizard')
|
|
}, [])
|
|
|
|
// Handle reset
|
|
const handleReset = useCallback(() => {
|
|
const emptyState = createEmptyScopeState()
|
|
setScopeState(emptyState)
|
|
setActiveTab('overview')
|
|
localStorage.removeItem(STORAGE_KEY)
|
|
}, [])
|
|
|
|
// Calculate completion statistics
|
|
const completionStats = useMemo(() => {
|
|
const allQuestions = getAllQuestions()
|
|
const requiredQuestions = allQuestions.filter(q => q.required)
|
|
const totalQuestions = requiredQuestions.length
|
|
const answeredIds = new Set(scopeState.answers.map(a => a.questionId))
|
|
const answeredQuestions = requiredQuestions.filter(q => answeredIds.has(q.id)).length
|
|
|
|
const completionPercentage = totalQuestions > 0
|
|
? Math.round((answeredQuestions / totalQuestions) * 100)
|
|
: 0
|
|
|
|
const isComplete = answeredQuestions === totalQuestions
|
|
|
|
return {
|
|
total: totalQuestions,
|
|
answered: answeredQuestions,
|
|
percentage: completionPercentage,
|
|
isComplete,
|
|
}
|
|
}, [scopeState.answers])
|
|
|
|
// Auto-enable evaluation when all questions are answered
|
|
const canEvaluate = useMemo(() => {
|
|
return completionStats.isComplete
|
|
}, [completionStats.isComplete])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="max-w-6xl mx-auto p-6">
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-gray-500">Loading...</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-6xl mx-auto space-y-6 p-6">
|
|
{/* Step Header */}
|
|
<StepHeader
|
|
stepId="compliance-scope"
|
|
title="Compliance Scope Engine"
|
|
description="Umfang und Tiefe Ihrer Compliance-Dokumentation bestimmen"
|
|
explanation="Die Scope Engine analysiert Ihr Unternehmen anhand von 35 Fragen in 6 Bereichen und bestimmt deterministisch, welche Dokumente in welcher Tiefe benoetigt werden. Das 4-Level-Modell reicht von L1 (Lean Startup) bis L4 (Zertifizierungsbereit). Hard Triggers wie Art. 9 Daten oder Minderjährige heben das Level automatisch an."
|
|
tips={[
|
|
{
|
|
icon: 'lightbulb',
|
|
title: 'Deterministisch',
|
|
description: 'Alle Entscheidungen sind nachvollziehbar — keine KI, keine Black Box. Jede Empfehlung hat eine auditfähige Begründung.'
|
|
},
|
|
{
|
|
icon: 'info',
|
|
title: '4-Level-Modell',
|
|
description: 'L1 (Lean Startup) bis L4 (Zertifizierungsbereit). Das Level bestimmt Dokumentationstiefe und -umfang.'
|
|
},
|
|
{
|
|
icon: 'warning',
|
|
title: 'Hard Triggers',
|
|
description: 'Besondere Datenkategorien (Art. 9), Minderjährige oder Zertifizierungsziele heben das Level automatisch an.'
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Progress Indicator */}
|
|
{completionStats.answered > 0 && (
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="text-sm font-medium text-purple-900">
|
|
Fortschritt: {completionStats.answered} von {completionStats.total} Fragen beantwortet
|
|
</div>
|
|
<div className="text-sm font-semibold text-purple-700">
|
|
{completionStats.percentage}%
|
|
</div>
|
|
</div>
|
|
<div className="w-full bg-purple-200 rounded-full h-2">
|
|
<div
|
|
className="bg-purple-600 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${completionStats.percentage}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Content Card */}
|
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
|
|
{/* Tab Navigation */}
|
|
<div className="border-b border-gray-200 px-6">
|
|
<nav className="flex gap-6 -mb-px">
|
|
{TABS.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`
|
|
flex items-center gap-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors
|
|
${activeTab === tab.id
|
|
? 'border-purple-600 text-purple-700'
|
|
: 'border-transparent text-gray-600 hover:text-gray-800 hover:border-gray-300'
|
|
}
|
|
`}
|
|
>
|
|
<span className="text-lg">{tab.icon}</span>
|
|
<span>{tab.label}</span>
|
|
{tab.id === 'wizard' && completionStats.answered > 0 && (
|
|
<span className="ml-1 px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-700">
|
|
{completionStats.percentage}%
|
|
</span>
|
|
)}
|
|
{tab.id === 'decision' && scopeState.decision && (
|
|
<span className="ml-1 w-2 h-2 rounded-full bg-green-500" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Tab Content — pb-28 verhindert dass sticky Footer die unteren Buttons verdeckt */}
|
|
<div className="p-6 pb-28">
|
|
{activeTab === 'overview' && (
|
|
<ScopeOverviewTab
|
|
scopeState={scopeState}
|
|
completionStats={completionStats}
|
|
onStartProfiling={handleStartProfiling}
|
|
onReset={handleReset}
|
|
onGoToWizard={() => setActiveTab('wizard')}
|
|
onGoToDecision={() => setActiveTab('decision')}
|
|
onGoToExport={() => setActiveTab('export')}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'wizard' && (
|
|
<ScopeWizardTab
|
|
answers={scopeState.answers}
|
|
onAnswersChange={handleAnswersChange}
|
|
onEvaluate={handleEvaluate}
|
|
canEvaluate={canEvaluate}
|
|
isEvaluating={isEvaluating}
|
|
completionStats={completionStats}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'decision' && (
|
|
<ScopeDecisionTab
|
|
decision={scopeState.decision}
|
|
answers={scopeState.answers}
|
|
onBackToWizard={() => setActiveTab('wizard')}
|
|
onGoToExport={() => setActiveTab('export')}
|
|
canEvaluate={canEvaluate}
|
|
onEvaluate={handleEvaluate}
|
|
isEvaluating={isEvaluating}
|
|
applicableRegulations={applicableRegulations}
|
|
supervisoryAuthorities={supervisoryAuthorities}
|
|
regulationAssessmentLoading={regulationAssessmentLoading}
|
|
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'export' && (
|
|
<ScopeExportTab
|
|
scopeState={scopeState}
|
|
onBackToDecision={() => setActiveTab('decision')}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Action Buttons (Fixed at bottom on mobile) */}
|
|
<div className="sticky bottom-6 flex justify-between items-center gap-4 bg-white rounded-lg border border-gray-200 p-4 shadow-lg">
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-sm text-gray-600">
|
|
{completionStats.isComplete ? (
|
|
<span className="flex items-center gap-2 text-green-700">
|
|
<span className="text-lg">✓</span>
|
|
<span className="font-medium">Profiling abgeschlossen</span>
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-2">
|
|
<span className="text-lg">📋</span>
|
|
<span>
|
|
{completionStats.answered === 0
|
|
? 'Starten Sie mit dem Profiling'
|
|
: `Noch ${completionStats.total - completionStats.answered} Fragen offen`
|
|
}
|
|
</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
{activeTab !== 'wizard' && completionStats.answered > 0 && (
|
|
<button
|
|
onClick={() => setActiveTab('wizard')}
|
|
className="px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors"
|
|
>
|
|
Zum Fragebogen
|
|
</button>
|
|
)}
|
|
|
|
{canEvaluate && activeTab !== 'decision' && (
|
|
<button
|
|
onClick={handleEvaluate}
|
|
disabled={isEvaluating}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isEvaluating ? 'Evaluiere...' : 'Scope evaluieren'}
|
|
</button>
|
|
)}
|
|
|
|
{scopeState.decision && activeTab !== 'export' && (
|
|
<button
|
|
onClick={() => setActiveTab('export')}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg transition-colors"
|
|
>
|
|
Exportieren
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Debug Info (only in development) */}
|
|
{process.env.NODE_ENV === 'development' && (
|
|
<details className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
|
<summary className="cursor-pointer text-sm font-medium text-gray-700">
|
|
Debug Information
|
|
</summary>
|
|
<div className="mt-3 space-y-2 text-xs font-mono">
|
|
<div>
|
|
<span className="font-semibold">Active Tab:</span> {activeTab}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Total Answers:</span> {scopeState.answers.length}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Answered:</span> {completionStats.answered} ({completionStats.percentage}%)
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Has Decision:</span> {scopeState.decision ? 'Yes' : 'No'}
|
|
</div>
|
|
{scopeState.decision && (
|
|
<>
|
|
<div>
|
|
<span className="font-semibold">Level:</span> {scopeState.decision.level}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Score:</span> {scopeState.decision.score}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Hard Triggers:</span> {scopeState.decision.hardTriggers.length}
|
|
</div>
|
|
</>
|
|
)}
|
|
<div>
|
|
<span className="font-semibold">Last Modified:</span> {scopeState.lastModified || 'Never'}
|
|
</div>
|
|
<div>
|
|
<span className="font-semibold">Can Evaluate:</span> {canEvaluate ? 'Yes' : 'No'}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|