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
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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user