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
|
return completionStats.isComplete
|
||||||
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto p-6">
|
<div className="max-w-6xl mx-auto p-6">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react'
|
import React, { useState, useCallback, useEffect, useMemo } from 'react'
|
||||||
import type { ScopeProfilingAnswer, ScopeProfilingQuestion } from '@/lib/sdk/compliance-scope-types'
|
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 type { ScopeQuestionBlockId } from '@/lib/sdk/compliance-scope-types'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
|
|
||||||
@@ -346,6 +346,10 @@ export function ScopeWizardTab({
|
|||||||
{SCOPE_QUESTION_BLOCKS.map((block, idx) => {
|
{SCOPE_QUESTION_BLOCKS.map((block, idx) => {
|
||||||
const progress = getBlockProgress(answers, block.id)
|
const progress = getBlockProgress(answers, block.id)
|
||||||
const isActive = idx === currentBlockIndex
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={block.id}
|
key={block.id}
|
||||||
@@ -361,13 +365,29 @@ export function ScopeWizardTab({
|
|||||||
<span className={`text-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>
|
<span className={`text-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>
|
||||||
{block.title}
|
{block.title}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs font-semibold ${isActive ? 'text-purple-600' : 'text-gray-500'}`}>
|
{allRequiredDone ? (
|
||||||
{progress}%
|
<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>
|
</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>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
||||||
<div
|
<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}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,6 +417,40 @@ export function ScopeWizardTab({
|
|||||||
style={{ width: `${totalProgress}%` }}
|
style={{ width: `${totalProgress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Current Block */}
|
{/* Current Block */}
|
||||||
@@ -455,11 +509,19 @@ export function ScopeWizardTab({
|
|||||||
|
|
||||||
{/* Questions */}
|
{/* Questions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{currentBlock.questions.map((question) => (
|
{currentBlock.questions.map((question) => {
|
||||||
<div key={question.id} className="border-b border-gray-100 pb-6 last:border-b-0 last:pb-0">
|
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)}
|
{renderQuestion(question)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -982,3 +982,29 @@ export function getAllQuestions(): ScopeProfilingQuestion[] {
|
|||||||
...HIDDEN_SCORING_QUESTIONS,
|
...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