Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 49s
CI/CD / test-python-backend-compliance (push) Successful in 46s
CI/CD / test-python-document-crawler (push) Successful in 30s
CI/CD / test-python-dsms-gateway (push) Successful in 31s
CI/CD / validate-canonical-controls (push) Successful in 23s
CI/CD / Deploy (push) Failing after 7s
- SDKSidebar (918→236 LOC): extracted icons to SidebarIcons, sub-components (ProgressBar, PackageIndicator, StepItem, CorpusStalenessInfo, AdditionalModuleItem) to SidebarSubComponents, and the full module nav list to SidebarModuleNav - ScopeWizardTab (794→339 LOC): extracted DatenkategorienBlock9 and its dept mapping constants to DatenkategorienBlock, and question rendering (all switch-case types + help text) to ScopeQuestionRenderer - All files now under 500 LOC hard cap; zero behavior changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
340 lines
15 KiB
TypeScript
340 lines
15 KiB
TypeScript
'use client'
|
|
import React, { useState, useCallback, useEffect } from 'react'
|
|
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
|
|
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
|
|
import type { ScopeQuestionBlockId } from '@/lib/sdk/compliance-scope-types'
|
|
import { useSDK } from '@/lib/sdk'
|
|
import { DatenkategorienBlock9 } from './DatenkategorienBlock'
|
|
import { ScopeQuestionRenderer } from './ScopeQuestionRenderer'
|
|
|
|
interface ScopeWizardTabProps {
|
|
answers: ScopeProfilingAnswer[]
|
|
onAnswersChange: (answers: ScopeProfilingAnswer[]) => void
|
|
onEvaluate: () => void
|
|
canEvaluate: boolean
|
|
isEvaluating: boolean
|
|
completionStats: { total: number; answered: number; percentage: number; isComplete: boolean }
|
|
}
|
|
|
|
export function ScopeWizardTab({
|
|
answers,
|
|
onAnswersChange,
|
|
onEvaluate,
|
|
canEvaluate,
|
|
isEvaluating,
|
|
completionStats,
|
|
}: ScopeWizardTabProps) {
|
|
const [currentBlockIndex, setCurrentBlockIndex] = useState(0)
|
|
const [expandedHelp, setExpandedHelp] = useState<Set<string>>(new Set())
|
|
const currentBlock = SCOPE_QUESTION_BLOCKS[currentBlockIndex]
|
|
const totalProgress = getTotalProgress(answers)
|
|
|
|
const { state: sdkState } = useSDK()
|
|
const companyProfile = sdkState.companyProfile
|
|
|
|
const [prefilledIds, setPrefilledIds] = useState<Set<string>>(new Set())
|
|
|
|
// Auto-prefill from company profile on mount if answers are empty
|
|
useEffect(() => {
|
|
if (companyProfile && answers.length === 0) {
|
|
const prefilled = prefillFromCompanyProfile(companyProfile)
|
|
const autoFilled = getAutoFilledScoringAnswers(companyProfile)
|
|
const allPrefilled = [...prefilled, ...autoFilled]
|
|
if (allPrefilled.length > 0) {
|
|
onAnswersChange(allPrefilled)
|
|
setPrefilledIds(new Set(allPrefilled.map(a => a.questionId)))
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
const handleAnswerChange = useCallback(
|
|
(questionId: string, value: string | string[] | boolean | number) => {
|
|
const existingIndex = answers.findIndex((a) => a.questionId === questionId)
|
|
if (existingIndex >= 0) {
|
|
const newAnswers = [...answers]
|
|
newAnswers[existingIndex] = { questionId, value }
|
|
onAnswersChange(newAnswers)
|
|
} else {
|
|
onAnswersChange([...answers, { questionId, value }])
|
|
}
|
|
if (prefilledIds.has(questionId)) {
|
|
setPrefilledIds(prev => {
|
|
const next = new Set(prev)
|
|
next.delete(questionId)
|
|
return next
|
|
})
|
|
}
|
|
},
|
|
[answers, onAnswersChange, prefilledIds]
|
|
)
|
|
|
|
const handlePrefillFromProfile = useCallback(() => {
|
|
if (!companyProfile) return
|
|
const prefilled = prefillFromCompanyProfile(companyProfile)
|
|
const autoFilled = getAutoFilledScoringAnswers(companyProfile)
|
|
const allPrefilled = [...prefilled, ...autoFilled]
|
|
const existingIds = new Set(answers.map(a => a.questionId))
|
|
const newAnswers = [...answers]
|
|
const newPrefilledIds = new Set(prefilledIds)
|
|
for (const pa of allPrefilled) {
|
|
if (!existingIds.has(pa.questionId)) {
|
|
newAnswers.push(pa)
|
|
newPrefilledIds.add(pa.questionId)
|
|
}
|
|
}
|
|
onAnswersChange(newAnswers)
|
|
setPrefilledIds(newPrefilledIds)
|
|
}, [companyProfile, answers, onAnswersChange, prefilledIds])
|
|
|
|
const handleNext = useCallback(() => {
|
|
if (currentBlockIndex < SCOPE_QUESTION_BLOCKS.length - 1) {
|
|
setCurrentBlockIndex(currentBlockIndex + 1)
|
|
} else if (canEvaluate) {
|
|
onEvaluate()
|
|
}
|
|
}, [currentBlockIndex, canEvaluate, onEvaluate])
|
|
|
|
const handleBack = useCallback(() => {
|
|
if (currentBlockIndex > 0) setCurrentBlockIndex(currentBlockIndex - 1)
|
|
}, [currentBlockIndex])
|
|
|
|
const toggleHelp = useCallback((questionId: string) => {
|
|
setExpandedHelp(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(questionId)) next.delete(questionId)
|
|
else next.add(questionId)
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
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
|
|
const unanswered = getUnansweredRequiredQuestions(answers, block.id)
|
|
const hasRequired = block.questions.some(q => q.required)
|
|
const allRequiredDone = hasRequired && unanswered.length === 0
|
|
const answeredIds = new Set(answers.map(a => a.questionId))
|
|
const hasAnyAnswer = block.questions.some(q => answeredIds.has(q.id))
|
|
const optionalDone = !hasRequired && hasAnyAnswer
|
|
|
|
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>
|
|
{allRequiredDone || optionalDone ? (
|
|
<span className="flex items-center gap-1 text-xs font-semibold text-green-600">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
{!hasRequired && <span>(optional)</span>}
|
|
</span>
|
|
) : !hasRequired ? (
|
|
<span className="text-xs text-gray-400">(nur optional)</span>
|
|
) : (
|
|
<span className="text-xs font-semibold text-orange-600">{unanswered.length} offen</span>
|
|
)}
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
|
<div
|
|
className={`h-full transition-all ${
|
|
allRequiredDone || optionalDone ? 'bg-green-500' : !hasRequired ? 'bg-gray-300' : 'bg-orange-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">
|
|
<span className="text-sm text-gray-500">
|
|
{completionStats.answered} / {completionStats.total} Fragen
|
|
</span>
|
|
<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>
|
|
|
|
{/* Clickable unanswered required questions summary */}
|
|
{(() => {
|
|
const allUnanswered = getUnansweredRequiredQuestions(answers)
|
|
if (allUnanswered.length === 0) return null
|
|
const byBlock = new Map<string, { blockTitle: string; blockIndex: number; count: number }>()
|
|
for (const item of allUnanswered) {
|
|
if (!byBlock.has(item.blockId)) {
|
|
const blockIndex = SCOPE_QUESTION_BLOCKS.findIndex(b => b.id === item.blockId)
|
|
byBlock.set(item.blockId, { blockTitle: item.blockTitle, blockIndex, count: 0 })
|
|
}
|
|
byBlock.get(item.blockId)!.count++
|
|
}
|
|
return (
|
|
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
|
|
<span className="text-orange-600 font-medium">⚠ Offene Pflichtfragen:</span>
|
|
{Array.from(byBlock.entries()).map(([blockId, info], i) => (
|
|
<React.Fragment key={blockId}>
|
|
{i > 0 && <span className="text-gray-300">·</span>}
|
|
<button
|
|
type="button"
|
|
onClick={() => setCurrentBlockIndex(info.blockIndex)}
|
|
className="text-orange-700 hover:text-orange-900 hover:underline font-medium"
|
|
>
|
|
{info.blockTitle} ({info.count})
|
|
</button>
|
|
</React.Fragment>
|
|
))}
|
|
</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 Profil uebernehmen
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* "Aus Profil" Info Box */}
|
|
{companyProfile && (() => {
|
|
const profileItems = getProfileInfoForBlock(companyProfile, currentBlock.id as ScopeQuestionBlockId)
|
|
if (profileItems.length === 0) return null
|
|
return (
|
|
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h4 className="text-sm font-medium text-blue-900 mb-2 flex items-center gap-2">
|
|
<svg className="w-4 h-4" 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" />
|
|
</svg>
|
|
Aus Unternehmensprofil
|
|
</h4>
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-blue-800">
|
|
{profileItems.map(item => (
|
|
<span key={item.label}>
|
|
<span className="font-medium">{item.label}:</span> {item.value}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<a
|
|
href="/sdk/company-profile"
|
|
className="text-sm text-blue-600 hover:text-blue-800 font-medium whitespace-nowrap flex items-center gap-1"
|
|
>
|
|
Profil bearbeiten
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{/* Questions */}
|
|
<div className="space-y-6">
|
|
{currentBlock.id === 'datenkategorien_detail' ? (
|
|
<DatenkategorienBlock9 answers={answers} onAnswerChange={handleAnswerChange} />
|
|
) : (
|
|
currentBlock.questions.map((question) => {
|
|
const isAnswered = answers.some(a => a.questionId === question.id)
|
|
const borderClass = question.required
|
|
? isAnswered ? 'border-l-4 border-l-green-400 pl-4' : 'border-l-4 border-l-orange-400 pl-4'
|
|
: ''
|
|
return (
|
|
<div key={question.id} className={`border-b border-gray-100 pb-6 last:border-b-0 last:pb-0 ${borderClass}`}>
|
|
<ScopeQuestionRenderer
|
|
question={question}
|
|
answers={answers}
|
|
prefilledIds={prefilledIds}
|
|
expandedHelp={expandedHelp}
|
|
onAnswerChange={handleAnswerChange}
|
|
onToggleHelp={toggleHelp}
|
|
/>
|
|
</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"
|
|
>
|
|
Zurueck
|
|
</button>
|
|
<span className="text-sm text-gray-600">
|
|
Block {currentBlockIndex + 1} von {SCOPE_QUESTION_BLOCKS.length}
|
|
</span>
|
|
{currentBlockIndex === SCOPE_QUESTION_BLOCKS.length - 1 ? (
|
|
<button
|
|
type="button"
|
|
onClick={onEvaluate}
|
|
disabled={!canEvaluate || isEvaluating}
|
|
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isEvaluating ? 'Evaluiere...' : 'Auswertung starten'}
|
|
</button>
|
|
) : (
|
|
<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"
|
|
>
|
|
Weiter
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|