feat(compliance-scope): Pflicht/Optional-Klassifikation, offene Fragen sichtbar + Navigation
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

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>
This commit is contained in:
Benjamin Admin
2026-03-10 11:45:46 +01:00
parent 1f91e05600
commit f6019ecba9
3 changed files with 105 additions and 10 deletions

View File

@@ -214,6 +214,13 @@ export default function ComplianceScopePage() {
return completionStats.isComplete
}, [completionStats.isComplete])
// Mark sidebar step as complete when all required questions answered AND decision exists
useEffect(() => {
if (completionStats.isComplete && scopeState.decision) {
dispatch({ type: 'COMPLETE_STEP', payload: 'compliance-scope' })
}
}, [completionStats.isComplete, scopeState.decision, dispatch])
if (isLoading) {
return (
<div className="max-w-6xl mx-auto p-6">

View File

@@ -1,7 +1,7 @@
'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 } from '@/lib/sdk/compliance-scope-profiling'
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'
@@ -346,6 +346,10 @@ export function ScopeWizardTab({
{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}
@@ -361,13 +365,29 @@ export function ScopeWizardTab({
<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>
{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 ${isActive ? 'bg-purple-500' : 'bg-gray-400'}`}
className={`h-full transition-all ${
allRequiredDone
? 'bg-green-500'
: !hasRequired
? 'bg-gray-300'
: 'bg-orange-400'
}`}
style={{ width: `${progress}%` }}
/>
</div>
@@ -397,6 +417,40 @@ export function ScopeWizardTab({
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 */}
@@ -455,11 +509,19 @@ export function ScopeWizardTab({
{/* 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>
))}
{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>

View File

@@ -982,3 +982,29 @@ export function getAllQuestions(): ScopeProfilingQuestion[] {
...HIDDEN_SCORING_QUESTIONS,
]
}
/**
* Get unanswered required questions, optionally filtered by block.
* Returns block metadata along with each question for navigation.
*/
export function getUnansweredRequiredQuestions(
answers: ScopeProfilingAnswer[],
blockId?: ScopeQuestionBlockId
): { blockId: ScopeQuestionBlockId; blockTitle: string; question: ScopeProfilingQuestion }[] {
const answeredIds = new Set(answers.map((a) => a.questionId))
const blocks = blockId
? SCOPE_QUESTION_BLOCKS.filter((b) => b.id === blockId)
: SCOPE_QUESTION_BLOCKS
const result: { blockId: ScopeQuestionBlockId; blockTitle: string; question: ScopeProfilingQuestion }[] = []
for (const block of blocks) {
for (const q of block.questions) {
if (q.required && !answeredIds.has(q.id)) {
result.push({ blockId: block.id, blockTitle: block.title, question: q })
}
}
}
return result
}