Files
breakpilot-compliance/admin-compliance/components/sdk/compliance-scope/ScopeWizardTab.tsx
Benjamin Admin f6019ecba9
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 37s
CI / test-python-backend-compliance (push) Successful in 35s
CI / test-python-document-crawler (push) Successful in 26s
CI / test-python-dsms-gateway (push) Successful in 20s
feat(compliance-scope): Pflicht/Optional-Klassifikation, offene Fragen sichtbar + Navigation
Block-Sidebar zeigt gruen/orange Status pro Block, klickbare Zusammenfassung
offener Pflichtfragen unter dem Fortschrittsbalken, und visuelles Highlighting
(linker Rand) fuer unbeantwortete Pflichtfragen. Sidebar-Haken wird gesetzt
wenn alle Pflichtfragen beantwortet und Auswertung vorhanden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:45:46 +01:00

564 lines
23 KiB
TypeScript

'use client'
import React, { useState, useCallback, useEffect, useMemo } from 'react'
import type { ScopeProfilingAnswer, ScopeProfilingQuestion } from '@/lib/sdk/compliance-scope-types'
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
import type { ScopeQuestionBlockId } from '@/lib/sdk/compliance-scope-types'
import { useSDK } from '@/lib/sdk'
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)
// Load companyProfile from SDK context
const { state: sdkState } = useSDK()
const companyProfile = sdkState.companyProfile
// Track which question IDs were prefilled from profile
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)
// Also inject auto-filled scoring answers for questions removed from UI
const autoFilled = getAutoFilledScoringAnswers(companyProfile)
const allPrefilled = [...prefilled, ...autoFilled]
if (allPrefilled.length > 0) {
onAnswersChange(allPrefilled)
setPrefilledIds(new Set(allPrefilled.map(a => a.questionId)))
}
}
// Only run on mount
// 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 }])
}
// Remove from prefilled set when user manually changes
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]
// Merge with existing answers: prefilled values for questions not yet answered
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
})
}, [])
// Check if a question was prefilled from company profile
const isPrefilledFromProfile = useCallback((questionId: string) => {
return prefilledIds.has(questionId)
}, [prefilledIds])
const renderHelpText = (question: ScopeProfilingQuestion) => {
if (!question.helpText) return null
return (
<>
<button
type="button"
className="ml-2 text-blue-400 hover:text-blue-600 inline-flex items-center"
onClick={(e) => {
e.preventDefault()
toggleHelp(question.id)
}}
>
<svg className="w-4 h-4" 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>
{expandedHelp.has(question.id) && (
<div className="flex items-start gap-2 mt-2 p-2.5 bg-blue-50 rounded-lg text-xs text-blue-700 leading-relaxed">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" 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>
<span>{question.helpText}</span>
</div>
)}
</>
)
}
const renderPrefilledBadge = (questionId: string) => {
if (!isPrefilledFromProfile(questionId)) return null
return (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
Aus Profil
</span>
)
}
const renderQuestion = (question: ScopeProfilingQuestion) => {
const currentValue = getAnswerValue(answers, question.id)
switch (question.type) {
case 'boolean':
return (
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
</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">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<div className="space-y-2">
{question.options?.map((option) => (
<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">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<div className="space-y-2">
{question.options?.map((option) => {
const selectedValues = Array.isArray(currentValue) ? currentValue as string[] : []
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">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<input
type="number"
value={currentValue != null ? String(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">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<input
type="text"
value={currentValue != null ? String(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
const unanswered = getUnansweredRequiredQuestions(answers, block.id)
const hasRequired = block.questions.some(q => q.required)
const allRequiredDone = hasRequired && unanswered.length === 0
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 ? (
<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>
</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
? '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
// Group by block
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 — shown for blocks that have auto-filled data */}
{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.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}`}>
{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"
>
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>
)
}