fix(sdk): Align scope types with engine output + project isolation + optional block progress
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 46s
CI/CD / test-python-backend-compliance (push) Successful in 42s
CI/CD / test-python-document-crawler (push) Successful in 29s
CI/CD / test-python-dsms-gateway (push) Successful in 25s
CI/CD / deploy-hetzner (push) Failing after 2s

Type alignment (root cause of client-side crash):
- RiskFlag: id/title/description → severity/category/message/recommendation
- ScopeGap: id/title/recommendation/relatedDocuments → gapType/currentState/targetState/effort
- NextAction: id/priority:number/effortDays → actionType/priority:string/estimatedEffort
- ScopeReasoning: details → factors + impact
- TriggeredHardTrigger: {rule: HardTriggerRule} → flat fields (ruleId, description, etc.)
- All UI components updated to match engine output shape

Project isolation:
- Scope localStorage key now includes projectId (prevents data leak between projects)

Optional block progress:
- Blocks with only optional questions now show green checkmark when any question answered

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-11 14:58:29 +01:00
parent 46048554cb
commit cb48b8289e
7 changed files with 185 additions and 152 deletions

View File

@@ -37,14 +37,31 @@ const TABS: { id: TabId; label: string; icon: string }[] = [
export default function ComplianceScopePage() { export default function ComplianceScopePage() {
const { state: sdkState, dispatch } = useSDK() const { state: sdkState, dispatch } = useSDK()
// Project-specific storage key
const projectStorageKey = sdkState.projectId
? `${STORAGE_KEY}_${sdkState.projectId}`
: STORAGE_KEY
// Active tab state // Active tab state
const [activeTab, setActiveTab] = useState<TabId>('overview') const [activeTab, setActiveTab] = useState<TabId>('overview')
// Migrate old decision format: drop decision if it has old-format fields
const migrateState = (state: ComplianceScopeState): ComplianceScopeState => {
if (state.decision) {
const d = state.decision as Record<string, unknown>
// Old format had 'level' instead of 'determinedLevel', or docs with 'isMandatory'
if (d.level || !d.determinedLevel) {
return { ...state, decision: null }
}
}
return state
}
// Local scope state // Local scope state
const [scopeState, setScopeState] = useState<ComplianceScopeState>(() => { const [scopeState, setScopeState] = useState<ComplianceScopeState>(() => {
// Try to load from SDK context first // Try to load from SDK context first
if (sdkState.complianceScope) { if (sdkState.complianceScope) {
return sdkState.complianceScope return migrateState(sdkState.complianceScope)
} }
return createEmptyScopeState() return createEmptyScopeState()
}) })
@@ -68,14 +85,14 @@ export default function ComplianceScopePage() {
const ctxScope = sdkState.complianceScope const ctxScope = sdkState.complianceScope
if (ctxScope && ctxScope.answers?.length > 0) { if (ctxScope && ctxScope.answers?.length > 0) {
syncingFromSdk.current = true syncingFromSdk.current = true
setScopeState(ctxScope) setScopeState(migrateState(ctxScope))
setIsLoading(false) setIsLoading(false)
} else if (isLoading) { } else if (isLoading) {
// SDK has no scope data — try localStorage fallback, then give up // SDK has no scope data — try localStorage fallback, then give up
try { try {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(projectStorageKey)
if (stored) { if (stored) {
const parsed = JSON.parse(stored) as ComplianceScopeState const parsed = migrateState(JSON.parse(stored) as ComplianceScopeState)
if (parsed.answers?.length > 0) { if (parsed.answers?.length > 0) {
setScopeState(parsed) setScopeState(parsed)
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed }) dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed })
@@ -106,7 +123,7 @@ export default function ComplianceScopePage() {
return return
} }
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(scopeState)) localStorage.setItem(projectStorageKey, JSON.stringify(scopeState))
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: scopeState }) dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: scopeState })
} catch (error) { } catch (error) {
console.error('Failed to save compliance scope state:', error) console.error('Failed to save compliance scope state:', error)
@@ -194,7 +211,7 @@ export default function ComplianceScopePage() {
const emptyState = createEmptyScopeState() const emptyState = createEmptyScopeState()
setScopeState(emptyState) setScopeState(emptyState)
setActiveTab('overview') setActiveTab('overview')
localStorage.removeItem(STORAGE_KEY) localStorage.removeItem(projectStorageKey)
}, []) }, [])
// Calculate completion statistics // Calculate completion statistics

View File

@@ -58,22 +58,23 @@ export function ScopeDecisionTab({
return 'from-green-500 to-green-600' return 'from-green-500 to-green-600'
} }
const getSeverityBadge = (severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL') => { const getSeverityBadge = (severity: string) => {
const colors = { const s = severity.toLowerCase()
LOW: 'bg-gray-100 text-gray-800', const colors: Record<string, string> = {
MEDIUM: 'bg-yellow-100 text-yellow-800', low: 'bg-gray-100 text-gray-800',
HIGH: 'bg-orange-100 text-orange-800', medium: 'bg-yellow-100 text-yellow-800',
CRITICAL: 'bg-red-100 text-red-800', high: 'bg-orange-100 text-orange-800',
critical: 'bg-red-100 text-red-800',
} }
const labels = { const labels: Record<string, string> = {
LOW: 'Niedrig', low: 'Niedrig',
MEDIUM: 'Mittel', medium: 'Mittel',
HIGH: 'Hoch', high: 'Hoch',
CRITICAL: 'Kritisch', critical: 'Kritisch',
} }
return ( return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[s] || colors.medium}`}>
{labels[severity]} {labels[s] || severity}
</span> </span>
) )
} }
@@ -254,9 +255,9 @@ export function ScopeDecisionTab({
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/> />
</svg> </svg>
<span className="font-medium text-gray-900">{trigger.rule.description}</span> <span className="font-medium text-gray-900">{trigger.description}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-red-200 text-red-800 font-medium"> <span className="text-xs px-2 py-0.5 rounded-full bg-red-200 text-red-800 font-medium">
Min. {trigger.rule.minimumLevel} Min. {trigger.minimumLevel}
</span> </span>
</div> </div>
<svg <svg
@@ -272,18 +273,18 @@ export function ScopeDecisionTab({
</button> </button>
{expandedTrigger === idx && ( {expandedTrigger === idx && (
<div className="px-4 pb-4 pt-2 border-t border-gray-200"> <div className="px-4 pb-4 pt-2 border-t border-gray-200">
<p className="text-sm text-gray-700 mb-2">{trigger.explanation}</p> <p className="text-sm text-gray-700 mb-2">{trigger.description}</p>
{trigger.rule.legalReference && ( {trigger.legalReference && (
<p className="text-xs text-gray-600 mb-2"> <p className="text-xs text-gray-600 mb-2">
<span className="font-medium">Rechtsgrundlage:</span> {trigger.rule.legalReference} <span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
</p> </p>
)} )}
{trigger.rule.mandatoryDocuments && trigger.rule.mandatoryDocuments.length > 0 && ( {trigger.mandatoryDocuments && trigger.mandatoryDocuments.length > 0 && (
<p className="text-xs text-gray-700"> <p className="text-xs text-gray-700">
<span className="font-medium">Pflichtdokumente:</span> {trigger.rule.mandatoryDocuments.join(', ')} <span className="font-medium">Pflichtdokumente:</span> {trigger.mandatoryDocuments.join(', ')}
</p> </p>
)} )}
{trigger.rule.dsfaRequired && ( {trigger.requiresDSFA && (
<p className="text-xs text-orange-700 font-medium mt-1">DSFA erforderlich</p> <p className="text-xs text-orange-700 font-medium mt-1">DSFA erforderlich</p>
)} )}
</div> </div>
@@ -302,10 +303,10 @@ export function ScopeDecisionTab({
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-gray-200"> <tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Typ</th> <th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Dokument</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Tiefe</th> <th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Priorität</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aufwand</th> <th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aufwand</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Status</th> <th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Trigger</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aktion</th> <th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aktion</th>
</tr> </tr>
</thead> </thead>
@@ -317,16 +318,16 @@ export function ScopeDecisionTab({
<span className="font-medium text-gray-900"> <span className="font-medium text-gray-900">
{DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} {DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType}
</span> </span>
{doc.required && ( {doc.requirement === 'mandatory' && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800"> <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
Pflicht Pflicht
</span> </span>
)} )}
</div> </div>
</td> </td>
<td className="py-3 px-4 text-sm text-gray-700">{doc.depth}</td> <td className="py-3 px-4 text-sm text-gray-700 capitalize">{doc.priority}</td>
<td className="py-3 px-4 text-sm text-gray-700"> <td className="py-3 px-4 text-sm text-gray-700">
{doc.estimatedEffort || '-'} {doc.estimatedEffort ? `${doc.estimatedEffort}h` : '-'}
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
{doc.triggeredBy && doc.triggeredBy.length > 0 && ( {doc.triggeredBy && doc.triggeredBy.length > 0 && (
@@ -361,10 +362,12 @@ export function ScopeDecisionTab({
{decision.riskFlags.map((flag, idx) => ( {decision.riskFlags.map((flag, idx) => (
<div key={idx} className="border border-gray-200 rounded-lg p-4"> <div key={idx} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-gray-900">{flag.title}</h4> <h4 className="font-semibold text-gray-900">{flag.message}</h4>
{getSeverityBadge(flag.severity)} {getSeverityBadge(flag.severity)}
</div> </div>
<p className="text-sm text-gray-700 mb-2">{flag.description}</p> {flag.legalReference && (
<p className="text-xs text-gray-500 mb-2">{flag.legalReference}</p>
)}
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
<span className="font-medium">Empfehlung:</span> {flag.recommendation} <span className="font-medium">Empfehlung:</span> {flag.recommendation}
</p> </p>
@@ -382,26 +385,19 @@ export function ScopeDecisionTab({
{decision.gaps.map((gap, idx) => ( {decision.gaps.map((gap, idx) => (
<div key={idx} className="border border-gray-200 rounded-lg p-4"> <div key={idx} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-gray-900">{gap.title}</h4> <h4 className="font-semibold text-gray-900">{gap.description}</h4>
{getSeverityBadge(gap.severity)} {getSeverityBadge(gap.severity)}
</div> </div>
<p className="text-sm text-gray-700 mb-2">{gap.description}</p> <p className="text-sm text-gray-700 mb-2">
<p className="text-sm text-gray-600 mb-2"> <span className="font-medium">Ist:</span> {gap.currentState}
<span className="font-medium">Empfehlung:</span> {gap.recommendation}
</p> </p>
{gap.relatedDocuments && gap.relatedDocuments.length > 0 && ( <p className="text-sm text-gray-600 mb-2">
<div className="mt-2"> <span className="font-medium">Soll:</span> {gap.targetState}
<span className="text-xs text-gray-500">Betroffene Dokumente: </span> </p>
{gap.relatedDocuments.map((doc, docIdx) => ( <div className="flex items-center gap-4 text-xs text-gray-500">
<span <span>Aufwand: ~{gap.effort}h</span>
key={docIdx} <span>Level: {gap.requiredFor}</span>
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 mr-1" </div>
>
{DOCUMENT_TYPE_LABELS[doc] || doc}
</span>
))}
</div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -416,21 +412,21 @@ export function ScopeDecisionTab({
{decision.nextActions.map((action, idx) => ( {decision.nextActions.map((action, idx) => (
<div key={idx} className="flex gap-4"> <div key={idx} className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center"> <div className="flex-shrink-0 w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-sm font-bold text-purple-700">{action.priority}</span> <span className="text-sm font-bold text-purple-700">{idx + 1}</span>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-1">{action.title}</h4> <h4 className="font-semibold text-gray-900 mb-1">{action.title}</h4>
<p className="text-sm text-gray-700 mb-2">{action.description}</p> <p className="text-sm text-gray-700 mb-2">{action.description}</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{action.effortDays && ( {action.estimatedEffort > 0 && (
<span className="text-xs text-gray-600"> <span className="text-xs text-gray-600">
<span className="font-medium">Aufwand:</span> {action.effortDays} Tage <span className="font-medium">Aufwand:</span> ~{action.estimatedEffort}h
</span> </span>
)} )}
{action.relatedDocuments && action.relatedDocuments.length > 0 && ( {action.sdkStepUrl && (
<span className="text-xs text-gray-600"> <a href={action.sdkStepUrl} className="text-xs text-purple-600 hover:text-purple-700">
<span className="font-medium">Dokumente:</span> {action.relatedDocuments.length} Zum SDK-Schritt
</span> </a>
)} )}
</div> </div>
</div> </div>
@@ -469,8 +465,8 @@ export function ScopeDecisionTab({
)} )}
</div> </div>
{/* Audit Trail */} {/* Audit Trail (from reasoning) */}
{decision.auditTrail && decision.auditTrail.length > 0 && ( {decision.reasoning && decision.reasoning.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
<button <button
type="button" type="button"
@@ -489,17 +485,20 @@ export function ScopeDecisionTab({
</button> </button>
{showAuditTrail && ( {showAuditTrail && (
<div className="space-y-3"> <div className="space-y-3">
{decision.auditTrail.map((entry, idx) => ( {decision.reasoning.map((entry, idx) => (
<div key={idx} className="border-l-2 border-purple-300 pl-4 py-2"> <div key={idx} className="border-l-2 border-purple-300 pl-4 py-2">
<h4 className="font-medium text-gray-900 mb-1">{entry.step}</h4> <h4 className="font-medium text-gray-900 mb-1">{entry.step}</h4>
<p className="text-sm text-gray-700 mb-2">{entry.description}</p> <p className="text-sm text-gray-700 mb-2">{entry.description}</p>
{entry.details && entry.details.length > 0 && ( {entry.factors && entry.factors.length > 0 && (
<ul className="text-xs text-gray-600 space-y-1"> <ul className="text-xs text-gray-600 space-y-1">
{entry.details.map((detail, detailIdx) => ( {entry.factors.map((factor, factorIdx) => (
<li key={detailIdx}> {detail}</li> <li key={factorIdx}> {factor}</li>
))} ))}
</ul> </ul>
)} )}
{entry.impact && (
<p className="text-xs text-purple-700 font-medium mt-1">{entry.impact}</p>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -31,12 +31,12 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
const handleDownloadCSV = useCallback(() => { const handleDownloadCSV = useCallback(() => {
if (!decision || !decision.requiredDocuments) return if (!decision || !decision.requiredDocuments) return
const headers = ['Typ', 'Tiefe', 'Aufwand (Tage)', 'Pflicht', 'Hard-Trigger'] const headers = ['Typ', 'Priorität', 'Aufwand (Stunden)', 'Pflicht', 'Hard-Trigger']
const rows = decision.requiredDocuments.map((doc) => [ const rows = decision.requiredDocuments.map((doc) => [
DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType, DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType,
doc.depth, doc.priority,
doc.estimatedEffort || '0', String(doc.estimatedEffort || 0),
doc.required ? 'Ja' : 'Nein', doc.requirement === 'mandatory' ? 'Ja' : 'Nein',
doc.triggeredBy.length > 0 ? 'Ja' : 'Nein', doc.triggeredBy.length > 0 ? 'Ja' : 'Nein',
]) ])
@@ -58,8 +58,8 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n` markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n`
markdown += `## Einstufung\n\n` markdown += `## Einstufung\n\n`
markdown += `**Level:** ${decision.determinedLevel} - ${DEPTH_LEVEL_LABELS[decision.determinedLevel]}\n\n` markdown += `**Level:** ${decision.determinedLevel} - ${DEPTH_LEVEL_LABELS[decision.determinedLevel]}\n\n`
if (decision.reasoning) { if (decision.reasoning && decision.reasoning.length > 0) {
markdown += `**Begründung:** ${decision.reasoning}\n\n` markdown += `**Begründung:** ${decision.reasoning.map(r => r.description).filter(Boolean).join('. ')}\n\n`
} }
if (decision.scores) { if (decision.scores) {
@@ -73,10 +73,10 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
if (decision.triggeredHardTriggers && decision.triggeredHardTriggers.length > 0) { if (decision.triggeredHardTriggers && decision.triggeredHardTriggers.length > 0) {
markdown += `## Aktive Hard-Trigger\n\n` markdown += `## Aktive Hard-Trigger\n\n`
decision.triggeredHardTriggers.forEach((trigger) => { decision.triggeredHardTriggers.forEach((trigger) => {
markdown += `- **${trigger.rule.label}**\n` markdown += `- **${trigger.description}**\n`
markdown += ` - ${trigger.rule.description}\n` markdown += ` - ${trigger.description}\n`
if (trigger.rule.legalReference) { if (trigger.legalReference) {
markdown += ` - Rechtsgrundlage: ${trigger.rule.legalReference}\n` markdown += ` - Rechtsgrundlage: ${trigger.legalReference}\n`
} }
}) })
markdown += `\n` markdown += `\n`
@@ -84,12 +84,12 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
if (decision.requiredDocuments && decision.requiredDocuments.length > 0) { if (decision.requiredDocuments && decision.requiredDocuments.length > 0) {
markdown += `## Erforderliche Dokumente\n\n` markdown += `## Erforderliche Dokumente\n\n`
markdown += `| Typ | Tiefe | Aufwand | Pflicht | Hard-Trigger |\n` markdown += `| Typ | Priorität | Aufwand (h) | Pflicht | Hard-Trigger |\n`
markdown += `|-----|-------|---------|---------|-------------|\n` markdown += `|-----|-----------|-------------|---------|-------------|\n`
decision.requiredDocuments.forEach((doc) => { decision.requiredDocuments.forEach((doc) => {
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.depth} | ${ markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.priority} | ${
doc.estimatedEffort || '0' doc.estimatedEffort || 0
} | ${doc.required ? 'Ja' : 'Nein'} | ${doc.triggeredBy.length > 0 ? 'Ja' : 'Nein'} |\n` } | ${doc.requirement === 'mandatory' ? 'Ja' : 'Nein'} | ${doc.triggeredBy.length > 0 ? 'Ja' : 'Nein'} |\n`
}) })
markdown += `\n` markdown += `\n`
} }
@@ -97,19 +97,29 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
if (decision.riskFlags && decision.riskFlags.length > 0) { if (decision.riskFlags && decision.riskFlags.length > 0) {
markdown += `## Risiko-Flags\n\n` markdown += `## Risiko-Flags\n\n`
decision.riskFlags.forEach((flag) => { decision.riskFlags.forEach((flag) => {
markdown += `### ${flag.title} (${flag.severity})\n\n` markdown += `### ${flag.message} (${flag.severity})\n\n`
markdown += `${flag.description}\n\n` if (flag.legalReference) markdown += `Rechtsgrundlage: ${flag.legalReference}\n\n`
markdown += `**Empfehlung:** ${flag.recommendation}\n\n` markdown += `**Empfehlung:** ${flag.recommendation}\n\n`
}) })
} }
if (decision.gaps && decision.gaps.length > 0) {
markdown += `## Gap-Analyse\n\n`
decision.gaps.forEach((gap) => {
markdown += `### ${gap.description} (${gap.severity})\n\n`
markdown += `- **Ist:** ${gap.currentState}\n`
markdown += `- **Soll:** ${gap.targetState}\n`
markdown += `- **Aufwand:** ~${gap.effort}h\n\n`
})
}
if (decision.nextActions && decision.nextActions.length > 0) { if (decision.nextActions && decision.nextActions.length > 0) {
markdown += `## Nächste Schritte\n\n` markdown += `## Nächste Schritte\n\n`
decision.nextActions.forEach((action) => { decision.nextActions.forEach((action, idx) => {
markdown += `${action.priority}. **${action.title}**\n` markdown += `${idx + 1}. **${action.title}**\n`
markdown += ` ${action.description}\n` markdown += ` ${action.description}\n`
if (action.estimatedEffort) { if (action.estimatedEffort) {
markdown += ` Aufwand: ${action.estimatedEffort}\n` markdown += ` Aufwand: ~${action.estimatedEffort}h\n`
} }
markdown += `\n` markdown += `\n`
}) })

View File

@@ -51,7 +51,7 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
} }
const renderLevelBadge = () => { const renderLevelBadge = () => {
if (!decision?.level) { if (!decision?.determinedLevel) {
return ( return (
<div className="bg-gray-100 border border-gray-300 rounded-xl p-8 text-center"> <div className="bg-gray-100 border border-gray-300 rounded-xl p-8 text-center">
<div className="inline-flex items-center justify-center w-24 h-24 bg-gray-200 rounded-full mb-4"> <div className="inline-flex items-center justify-center w-24 h-24 bg-gray-200 rounded-full mb-4">
@@ -80,13 +80,7 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
} }
const renderActiveHardTriggers = () => { const renderActiveHardTriggers = () => {
if (!decision?.hardTriggers || decision.triggeredHardTriggers.length === 0) { if (!decision?.triggeredHardTriggers || decision.triggeredHardTriggers.length === 0) {
return null
}
const activeHardTriggers = decision.triggeredHardTriggers.filter((ht) => ht.matched)
if (activeHardTriggers.length === 0) {
return null return null
} }
@@ -104,23 +98,22 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
<h3 className="text-lg font-semibold text-gray-900">Aktive Hard-Trigger</h3> <h3 className="text-lg font-semibold text-gray-900">Aktive Hard-Trigger</h3>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{activeHardTriggers.map((trigger, idx) => ( {decision.triggeredHardTriggers.map((trigger, idx) => (
<div <div
key={idx} key={idx}
className="border-l-4 border-red-500 bg-red-50 rounded-r-lg p-4" className="border-l-4 border-red-500 bg-red-50 rounded-r-lg p-4"
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<h4 className="font-semibold text-gray-900">{trigger.label}</h4> <h4 className="font-semibold text-gray-900">{trigger.description}</h4>
<p className="text-sm text-gray-600 mt-1">{trigger.description}</p>
{trigger.legalReference && ( {trigger.legalReference && (
<p className="text-xs text-gray-500 mt-2"> <p className="text-xs text-gray-500 mt-2">
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference} <span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
</p> </p>
)} )}
{trigger.matchedValue && ( {trigger.minimumLevel && (
<p className="text-xs text-gray-700 mt-1"> <p className="text-xs text-gray-700 mt-1">
<span className="font-medium">Erfasster Wert:</span> {trigger.matchedValue} <span className="font-medium">Mindest-Level:</span> {trigger.minimumLevel}
</p> </p>
)} )}
</div> </div>
@@ -137,12 +130,9 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
return null return null
} }
const mandatoryDocs = decision.requiredDocuments.filter((doc) => doc.isMandatory) const mandatoryDocs = decision.requiredDocuments.filter((doc) => doc.requirement === 'mandatory')
const optionalDocs = decision.requiredDocuments.filter((doc) => !doc.isMandatory) const optionalDocs = decision.requiredDocuments.filter((doc) => doc.requirement === 'recommended')
const totalEffortDays = decision.requiredDocuments.reduce( const totalEffortHours = decision.requiredDocuments.reduce((sum, doc) => sum + (doc.estimatedEffort ?? 0), 0)
(sum, doc) => sum + (doc.effortEstimate?.days ?? 0),
0
)
return ( return (
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
@@ -157,8 +147,8 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
<div className="text-sm text-gray-600 mt-1">Optional</div> <div className="text-sm text-gray-600 mt-1">Optional</div>
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-3xl font-bold text-gray-900">{totalEffortDays}</div> <div className="text-3xl font-bold text-gray-900">{totalEffortHours}h</div>
<div className="text-sm text-gray-600 mt-1">Tage Aufwand (geschätzt)</div> <div className="text-sm text-gray-600 mt-1">Aufwand (geschätzt)</div>
</div> </div>
</div> </div>
</div> </div>
@@ -227,11 +217,11 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
<div className="bg-white rounded-xl border border-gray-200 p-6"> <div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Übersicht</h3> <h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Übersicht</h3>
<div className="space-y-4"> <div className="space-y-4">
{renderScoreGauge('Risiko-Score', decision.scores?.riskScore)} {renderScoreGauge('Risiko-Score', decision.scores?.risk_score)}
{renderScoreGauge('Komplexitäts-Score', decision.scores?.complexityScore)} {renderScoreGauge('Komplexitäts-Score', decision.scores?.complexity_score)}
{renderScoreGauge('Assurance-Score', decision.scores?.assuranceScore)} {renderScoreGauge('Assurance-Score', decision.scores?.assurance_need)}
<div className="pt-4 border-t border-gray-200"> <div className="pt-4 border-t border-gray-200">
{renderScoreGauge('Gesamt-Score', decision.scores?.compositeScore)} {renderScoreGauge('Gesamt-Score', decision.scores?.composite_score)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -350,6 +350,10 @@ export function ScopeWizardTab({
const unanswered = getUnansweredRequiredQuestions(answers, block.id) const unanswered = getUnansweredRequiredQuestions(answers, block.id)
const hasRequired = block.questions.some(q => q.required) const hasRequired = block.questions.some(q => q.required)
const allRequiredDone = hasRequired && unanswered.length === 0 const allRequiredDone = hasRequired && unanswered.length === 0
// For optional-only blocks: check if any questions were answered
const answeredIds = new Set(answers.map(a => a.questionId))
const hasAnyAnswer = block.questions.some(q => answeredIds.has(q.id))
const optionalDone = !hasRequired && hasAnyAnswer
return ( return (
<button <button
@@ -366,11 +370,12 @@ 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>
{allRequiredDone ? ( {allRequiredDone || optionalDone ? (
<span className="flex items-center gap-1 text-xs font-semibold text-green-600"> <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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg> </svg>
{!hasRequired && <span>(optional)</span>}
</span> </span>
) : !hasRequired ? ( ) : !hasRequired ? (
<span className="text-xs text-gray-400">(nur optional)</span> <span className="text-xs text-gray-400">(nur optional)</span>
@@ -383,7 +388,7 @@ export function ScopeWizardTab({
<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 ${ className={`h-full transition-all ${
allRequiredDone allRequiredDone || optionalDone
? 'bg-green-500' ? 'bg-green-500'
: !hasRequired : !hasRequired
? 'bg-gray-300' ? 'bg-gray-300'

View File

@@ -1664,7 +1664,7 @@ export class ComplianceScopeEngine {
step: 'hard_trigger_evaluation', step: 'hard_trigger_evaluation',
description: `${triggers.length} Hard Trigger Rule(s) aktiviert`, description: `${triggers.length} Hard Trigger Rule(s) aktiviert`,
factors: triggers.map( factors: triggers.map(
(t) => `${t.ruleId}: ${t.description} (${t.legalReference})` (t) => `${t.ruleId}: ${t.description}${t.legalReference ? ` (${t.legalReference})` : ''}`
), ),
impact: `Höchstes Trigger-Level: ${this.getMaxTriggerLevel(triggers)}`, impact: `Höchstes Trigger-Level: ${this.getMaxTriggerLevel(triggers)}`,
}) })

View File

@@ -154,12 +154,20 @@ export interface HardTriggerRule {
* Getriggerter Hard Trigger mit Kontext * Getriggerter Hard Trigger mit Kontext
*/ */
export interface TriggeredHardTrigger { export interface TriggeredHardTrigger {
/** Die getriggerte Regel */ /** Regel-ID */
rule: HardTriggerRule; ruleId: string;
/** Der tatsächlich gefundene Wert */ /** Kategorie */
matchedValue: unknown; category: string;
/** Erklärung warum getriggert */ /** Beschreibung */
explanation: string; description: string;
/** Rechtsgrundlage */
legalReference?: string;
/** Mindest-Level */
minimumLevel: ComplianceDepthLevel;
/** DSFA erforderlich? */
requiresDSFA: boolean;
/** Pflichtdokumente */
mandatoryDocuments: string[];
} }
// ============================================================================ // ============================================================================
@@ -229,14 +237,12 @@ export interface RequiredDocument {
documentType: ScopeDocumentType; documentType: ScopeDocumentType;
/** Anzeigename */ /** Anzeigename */
label: string; label: string;
/** Ist Pflicht? */ /** Pflicht oder empfohlen */
required: boolean; requirement: 'mandatory' | 'recommended';
/** Erforderliche Tiefe (z.B. "Basis", "Standard", "Detailliert") */ /** Priorität */
depth: string; priority: 'high' | 'medium' | 'low';
/** Konkrete Anforderungen/Inhalte */ /** Geschätzter Aufwand in Stunden */
detailItems: string[]; estimatedEffort: number;
/** Geschätzter Aufwand */
estimatedEffort: string;
/** Von welchen Triggern/Regeln gefordert */ /** Von welchen Triggern/Regeln gefordert */
triggeredBy: string[]; triggeredBy: string[];
/** Link zum SDK-Schritt */ /** Link zum SDK-Schritt */
@@ -247,14 +253,12 @@ export interface RequiredDocument {
* Risiko-Flag * Risiko-Flag
*/ */
export interface RiskFlag { export interface RiskFlag {
/** Eindeutige ID */
id: string;
/** Schweregrad */ /** Schweregrad */
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; severity: string;
/** Titel */ /** Kategorie */
title: string; category: string;
/** Beschreibung */ /** Beschreibung */
description: string; message: string;
/** Rechtsgrundlage */ /** Rechtsgrundlage */
legalReference?: string; legalReference?: string;
/** Empfehlung zur Behebung */ /** Empfehlung zur Behebung */
@@ -265,38 +269,44 @@ export interface RiskFlag {
* Identifizierte Lücke in der Compliance * Identifizierte Lücke in der Compliance
*/ */
export interface ScopeGap { export interface ScopeGap {
/** Eindeutige ID */ /** Gap-Typ */
id: string; gapType: string;
/** Schweregrad */ /** Schweregrad */
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; severity: string;
/** Titel */
title: string;
/** Beschreibung */ /** Beschreibung */
description: string; description: string;
/** Empfehlung zur Schließung */ /** Erforderlich für Level */
recommendation: string; requiredFor: ComplianceDepthLevel;
/** Betroffene Dokumente */ /** Aktueller Zustand */
relatedDocuments: ScopeDocumentType[]; currentState: string;
/** Zielzustand */
targetState: string;
/** Aufwand in Stunden */
effort: number;
/** Priorität */
priority: string;
} }
/** /**
* Nächster empfohlener Schritt * Nächster empfohlener Schritt
*/ */
export interface NextAction { export interface NextAction {
/** Eindeutige ID */ /** Aktionstyp */
id: string; actionType: 'create_document' | 'establish_process' | 'implement_technical' | 'organizational_change';
/** Priorität (1 = höchste) */
priority: number;
/** Titel */ /** Titel */
title: string; title: string;
/** Beschreibung */ /** Beschreibung */
description: string; description: string;
/** Geschätzter Aufwand */ /** Priorität */
estimatedEffort: string; priority: string;
/** Betroffene Dokumente */ /** Geschätzter Aufwand in Stunden */
relatedDocuments: ScopeDocumentType[]; estimatedEffort: number;
/** Dokumenttyp (optional) */
documentType?: ScopeDocumentType;
/** Link zum SDK-Schritt */ /** Link zum SDK-Schritt */
sdkStepUrl?: string; sdkStepUrl?: string;
/** Blocker */
blockers: string[];
} }
/** /**
@@ -307,8 +317,10 @@ export interface ScopeReasoning {
step: string; step: string;
/** Kurzbeschreibung */ /** Kurzbeschreibung */
description: string; description: string;
/** Detaillierte Punkte */ /** Faktoren */
details: string[]; factors: string[];
/** Auswirkung */
impact: string;
} }
// ============================================================================ // ============================================================================