SDK modules added/enhanced: - compliance-hub, compliance-scope, consent-management, notfallplan - audit-report, workflow, source-policy, dsms - advisory-board documentation section - TOM dashboard components, TOM generator SDM mapping - DSFA: mitigation library, risk catalog, threshold analysis, source attribution - VVT: baseline catalog, profiling engine, types - Loeschfristen: baseline catalog, compliance engine, export, profiling, types - Compliance scope: engine, profiling, golden tests, types Existing SDK pages updated: - dsfa/[id], tom, vvt, loeschfristen, advisory-board — expanded functionality - SDKSidebar, StepHeader — new navigation items and layout - SDK layout, context, types — expanded type system Other admin-v2 changes: - AI agents page, RAG pipeline DSFA integration - GridOverlay component updates - Companion feature (development + education) - Compliance advisor SOUL definition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
411 lines
16 KiB
TypeScript
411 lines
16 KiB
TypeScript
'use client'
|
|
import React, { useState, useCallback } from 'react'
|
|
import type { ScopeProfilingAnswer, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
|
|
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, getAllQuestions } from '@/lib/sdk/compliance-scope-profiling'
|
|
import type { CompanyProfile } from '@/lib/sdk/types'
|
|
import { prefillFromCompanyProfile } from '@/lib/sdk/compliance-scope-profiling'
|
|
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_COLORS } from '@/lib/sdk/compliance-scope-types'
|
|
|
|
interface ScopeWizardTabProps {
|
|
answers: ScopeProfilingAnswer[]
|
|
onAnswersChange: (answers: ScopeProfilingAnswer[]) => void
|
|
onComplete: () => void
|
|
companyProfile: CompanyProfile | null
|
|
currentLevel: ComplianceDepthLevel | null
|
|
}
|
|
|
|
export function ScopeWizardTab({
|
|
answers,
|
|
onAnswersChange,
|
|
onComplete,
|
|
companyProfile,
|
|
currentLevel,
|
|
}: ScopeWizardTabProps) {
|
|
const [currentBlockIndex, setCurrentBlockIndex] = useState(0)
|
|
const currentBlock = SCOPE_QUESTION_BLOCKS[currentBlockIndex]
|
|
const totalProgress = getTotalProgress(answers)
|
|
|
|
const handleAnswerChange = useCallback(
|
|
(questionId: string, value: any) => {
|
|
const existingIndex = answers.findIndex((a) => a.questionId === questionId)
|
|
if (existingIndex >= 0) {
|
|
const newAnswers = [...answers]
|
|
newAnswers[existingIndex] = { questionId, value, answeredAt: new Date().toISOString() }
|
|
onAnswersChange(newAnswers)
|
|
} else {
|
|
onAnswersChange([...answers, { questionId, value, answeredAt: new Date().toISOString() }])
|
|
}
|
|
},
|
|
[answers, onAnswersChange]
|
|
)
|
|
|
|
const handlePrefillFromProfile = useCallback(() => {
|
|
if (!companyProfile) return
|
|
const prefilledAnswers = prefillFromCompanyProfile(companyProfile, answers)
|
|
onAnswersChange(prefilledAnswers)
|
|
}, [companyProfile, answers, onAnswersChange])
|
|
|
|
const handleNext = useCallback(() => {
|
|
if (currentBlockIndex < SCOPE_QUESTION_BLOCKS.length - 1) {
|
|
setCurrentBlockIndex(currentBlockIndex + 1)
|
|
} else {
|
|
onComplete()
|
|
}
|
|
}, [currentBlockIndex, onComplete])
|
|
|
|
const handleBack = useCallback(() => {
|
|
if (currentBlockIndex > 0) {
|
|
setCurrentBlockIndex(currentBlockIndex - 1)
|
|
}
|
|
}, [currentBlockIndex])
|
|
|
|
const renderQuestion = (question: any) => {
|
|
const currentValue = getAnswerValue(answers, question.id)
|
|
|
|
switch (question.type) {
|
|
case 'boolean':
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium text-gray-900">
|
|
{question.question}
|
|
{question.required && <span className="text-red-500 ml-1">*</span>}
|
|
</label>
|
|
{question.helpText && (
|
|
<button
|
|
type="button"
|
|
className="text-gray-400 hover:text-gray-600"
|
|
title={question.helpText}
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleAnswerChange(question.id, true)}
|
|
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
|
|
currentValue === true
|
|
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
|
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
|
}`}
|
|
>
|
|
Ja
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleAnswerChange(question.id, false)}
|
|
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
|
|
currentValue === false
|
|
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
|
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
|
}`}
|
|
>
|
|
Nein
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
case 'single':
|
|
return (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-gray-900">
|
|
{question.question}
|
|
{question.required && <span className="text-red-500 ml-1">*</span>}
|
|
{question.helpText && (
|
|
<button
|
|
type="button"
|
|
className="ml-2 text-gray-400 hover:text-gray-600 inline"
|
|
title={question.helpText}
|
|
>
|
|
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</label>
|
|
<div className="space-y-2">
|
|
{question.options?.map((option: any) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => handleAnswerChange(question.id, option.value)}
|
|
className={`w-full text-left py-3 px-4 rounded-lg border-2 transition-all ${
|
|
currentValue === option.value
|
|
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
|
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
case 'multi':
|
|
return (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-gray-900">
|
|
{question.question}
|
|
{question.required && <span className="text-red-500 ml-1">*</span>}
|
|
{question.helpText && (
|
|
<button
|
|
type="button"
|
|
className="ml-2 text-gray-400 hover:text-gray-600 inline"
|
|
title={question.helpText}
|
|
>
|
|
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</label>
|
|
<div className="space-y-2">
|
|
{question.options?.map((option: any) => {
|
|
const selectedValues = Array.isArray(currentValue) ? currentValue : []
|
|
const isChecked = selectedValues.includes(option.value)
|
|
return (
|
|
<label
|
|
key={option.value}
|
|
className={`flex items-center gap-3 py-3 px-4 rounded-lg border-2 cursor-pointer transition-all ${
|
|
isChecked
|
|
? 'border-purple-500 bg-purple-50'
|
|
: 'border-gray-300 bg-white hover:border-gray-400'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isChecked}
|
|
onChange={(e) => {
|
|
const newValues = e.target.checked
|
|
? [...selectedValues, option.value]
|
|
: selectedValues.filter((v) => v !== option.value)
|
|
handleAnswerChange(question.id, newValues)
|
|
}}
|
|
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
|
|
/>
|
|
<span className={isChecked ? 'text-purple-700 font-medium' : 'text-gray-700'}>
|
|
{option.label}
|
|
</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
case 'number':
|
|
return (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-gray-900">
|
|
{question.question}
|
|
{question.required && <span className="text-red-500 ml-1">*</span>}
|
|
{question.helpText && (
|
|
<button
|
|
type="button"
|
|
className="ml-2 text-gray-400 hover:text-gray-600 inline"
|
|
title={question.helpText}
|
|
>
|
|
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={currentValue ?? ''}
|
|
onChange={(e) => handleAnswerChange(question.id, parseInt(e.target.value, 10))}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
placeholder="Zahl eingeben"
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
case 'text':
|
|
return (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-gray-900">
|
|
{question.question}
|
|
{question.required && <span className="text-red-500 ml-1">*</span>}
|
|
{question.helpText && (
|
|
<button
|
|
type="button"
|
|
className="ml-2 text-gray-400 hover:text-gray-600 inline"
|
|
title={question.helpText}
|
|
>
|
|
<svg className="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={currentValue ?? ''}
|
|
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
|
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
placeholder="Text eingeben"
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex gap-6 h-full">
|
|
{/* Left Sidebar - Block Navigation */}
|
|
<div className="w-64 flex-shrink-0">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4 sticky top-4">
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fortschritt</h3>
|
|
<div className="space-y-2">
|
|
{SCOPE_QUESTION_BLOCKS.map((block, idx) => {
|
|
const progress = getBlockProgress(answers, block.id)
|
|
const isActive = idx === currentBlockIndex
|
|
return (
|
|
<button
|
|
key={block.id}
|
|
type="button"
|
|
onClick={() => setCurrentBlockIndex(idx)}
|
|
className={`w-full text-left px-3 py-2 rounded-lg transition-all ${
|
|
isActive
|
|
? 'bg-purple-50 border-2 border-purple-500'
|
|
: 'bg-gray-50 border border-gray-200 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className={`text-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>
|
|
{block.title}
|
|
</span>
|
|
<span className={`text-xs font-semibold ${isActive ? 'text-purple-600' : 'text-gray-500'}`}>
|
|
{progress}%
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
|
<div
|
|
className={`h-full transition-all ${isActive ? 'bg-purple-500' : 'bg-gray-400'}`}
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="flex-1 space-y-6">
|
|
{/* Progress Bar */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-gray-700">Gesamtfortschritt</span>
|
|
<div className="flex items-center gap-3">
|
|
{currentLevel && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-gray-500">Vorläufige Einstufung:</span>
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold ${DEPTH_LEVEL_COLORS[currentLevel].badge} ${DEPTH_LEVEL_COLORS[currentLevel].text}`}
|
|
>
|
|
{currentLevel} - {DEPTH_LEVEL_LABELS[currentLevel]}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<span className="text-sm font-bold text-gray-900">{totalProgress}%</span>
|
|
</div>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
|
|
style={{ width: `${totalProgress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Current Block */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="flex items-start justify-between mb-6">
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">{currentBlock.title}</h2>
|
|
<p className="text-gray-600">{currentBlock.description}</p>
|
|
</div>
|
|
{companyProfile && (
|
|
<button
|
|
type="button"
|
|
onClick={handlePrefillFromProfile}
|
|
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 border border-blue-300 rounded-lg hover:bg-blue-100 transition-colors whitespace-nowrap"
|
|
>
|
|
Aus Unternehmensprofil übernehmen
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Questions */}
|
|
<div className="space-y-6">
|
|
{currentBlock.questions.map((question) => (
|
|
<div key={question.id} className="border-b border-gray-100 pb-6 last:border-b-0 last:pb-0">
|
|
{renderQuestion(question)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation Buttons */}
|
|
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
|
|
<button
|
|
type="button"
|
|
onClick={handleBack}
|
|
disabled={currentBlockIndex === 0}
|
|
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Zurück
|
|
</button>
|
|
<span className="text-sm text-gray-600">
|
|
Block {currentBlockIndex + 1} von {SCOPE_QUESTION_BLOCKS.length}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleNext}
|
|
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
|
|
>
|
|
{currentBlockIndex === SCOPE_QUESTION_BLOCKS.length - 1 ? 'Auswertung starten' : 'Weiter'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|