merge: phases 1–5 refactor, CI hardening, docs (coolify → main)
Some checks failed
Build + Deploy / build-admin-compliance (push) Failing after 47s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-ai-sdk (push) Successful in 34s
Build + Deploy / build-developer-portal (push) Successful in 56s
Build + Deploy / build-tts (push) Successful in 26s
Build + Deploy / build-document-crawler (push) Successful in 15s
Build + Deploy / build-dsms-gateway (push) Successful in 13s
Build + Deploy / trigger-orca (push) Has been skipped
CI/CD / loc-budget (push) Successful in 22s
CI/CD / guardrail-integrity (push) Has been skipped
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been cancelled
CI/CD / test-go-ai-compliance (push) Has been cancelled
CI/CD / test-python-backend-compliance (push) Has been cancelled
CI/CD / test-python-document-crawler (push) Has been cancelled
CI/CD / test-python-dsms-gateway (push) Successful in 28s
CI/CD / sbom-scan (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Successful in 20s
Some checks failed
Build + Deploy / build-admin-compliance (push) Failing after 47s
Build + Deploy / build-backend-compliance (push) Successful in 11s
Build + Deploy / build-ai-sdk (push) Successful in 34s
Build + Deploy / build-developer-portal (push) Successful in 56s
Build + Deploy / build-tts (push) Successful in 26s
Build + Deploy / build-document-crawler (push) Successful in 15s
Build + Deploy / build-dsms-gateway (push) Successful in 13s
Build + Deploy / trigger-orca (push) Has been skipped
CI/CD / loc-budget (push) Successful in 22s
CI/CD / guardrail-integrity (push) Has been skipped
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been cancelled
CI/CD / test-go-ai-compliance (push) Has been cancelled
CI/CD / test-python-backend-compliance (push) Has been cancelled
CI/CD / test-python-document-crawler (push) Has been cancelled
CI/CD / test-python-dsms-gateway (push) Successful in 28s
CI/CD / sbom-scan (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Successful in 20s
Phase 1: backend-compliance — partial service-layer extraction Phase 2: ai-compliance-sdk — full hexagonal split; iace/ucca/training handlers and stores split into focused files; cmd/server/main.go → internal/app/ Phase 3: admin-compliance — types.ts, tom-generator loader, and major page components split; lib document generators extracted Phase 4: dsms-gateway, consent-sdk, developer-portal, breakpilot-compliance-sdk Phase 5 CI hardening: - loc-budget job now scans whole repo (blocking, no || true) - sbom-scan / grype blocking on high+ CVEs - ai-compliance-sdk/.golangci.yml: strict golangci-lint config - check-loc.sh: skip test_*.py and *.html; loc-exceptions.txt expanded - deleted stray routes.py.backup (2512 LOC) Docs: - root README.md with CI badge, service table, quick start, CI pipeline table - CONTRIBUTING.md: setup, pre-commit checklist, guardrail marker reference - CLAUDE.md: First-Time Setup & Claude Code Onboarding section - all 7 service READMEs updated (stale phase refs, current architecture) - AGENTS.go/python/typescript.md enhanced with linting, DI, barrel re-export - .gitignore: dist/, .turbo/, pnpm-lock.yaml added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
// =============================================================================
|
||||
// BLOCK 9: Datenkategorien pro Abteilung (aufklappbare Kacheln)
|
||||
// Extracted from ScopeWizardTab for LOC compliance.
|
||||
// =============================================================================
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
|
||||
import { DEPARTMENT_DATA_CATEGORIES } from '@/lib/sdk/vvt-profiling'
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
/** Mapping Block 8 vvt_departments values → DEPARTMENT_DATA_CATEGORIES keys */
|
||||
export const DEPT_VALUE_TO_KEY: Record<string, string[]> = {
|
||||
personal: ['dept_hr', 'dept_recruiting'],
|
||||
@@ -38,16 +39,15 @@ export const DEPT_KEY_TO_QUESTION: Record<string, string> = {
|
||||
dept_facility: 'dk_dept_facility',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DATENKATEGORIEN BLOCK 9
|
||||
// =============================================================================
|
||||
|
||||
interface DatenkategorienBlock9Props {
|
||||
answers: ScopeProfilingAnswer[]
|
||||
onAnswerChange: (questionId: string, value: string | string[] | boolean | number) => void
|
||||
}
|
||||
|
||||
export function DatenkategorienBlock9({ answers, onAnswerChange }: DatenkategorienBlock9Props) {
|
||||
export function DatenkategorienBlock9({
|
||||
answers,
|
||||
onAnswerChange,
|
||||
}: DatenkategorienBlock9Props) {
|
||||
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set())
|
||||
const [initializedDepts, setInitializedDepts] = useState<Set<string>>(new Set())
|
||||
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client'
|
||||
|
||||
import type { ScopeDecision, ApplicableRegulation, SupervisoryAuthorityInfo } from '@/lib/sdk/compliance-scope-types'
|
||||
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
export const getScoreColor = (score: number): string => {
|
||||
if (score >= 80) return 'from-red-500 to-red-600'
|
||||
if (score >= 60) return 'from-orange-500 to-orange-600'
|
||||
if (score >= 40) return 'from-yellow-500 to-yellow-600'
|
||||
return 'from-green-500 to-green-600'
|
||||
}
|
||||
|
||||
export const getSeverityBadge = (severity: string) => {
|
||||
const s = severity.toLowerCase()
|
||||
const colors: Record<string, string> = {
|
||||
low: 'bg-gray-100 text-gray-800',
|
||||
medium: 'bg-yellow-100 text-yellow-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
low: 'Niedrig',
|
||||
medium: 'Mittel',
|
||||
high: 'Hoch',
|
||||
critical: 'Kritisch',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[s] || colors.medium}`}>
|
||||
{labels[s] || severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const ScoreBar = ({ label, score }: { label: string; score: number | undefined }) => {
|
||||
const value = score ?? 0
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
<span className="text-sm font-bold text-gray-900">{value}/100</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${getScoreColor(value)} transition-all duration-500`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LevelCard
|
||||
// =============================================================================
|
||||
|
||||
export function LevelCard({ decision }: { decision: ScopeDecision }) {
|
||||
return (
|
||||
<div className={`${DEPTH_LEVEL_COLORS[decision.determinedLevel].bg} border-2 ${DEPTH_LEVEL_COLORS[decision.determinedLevel].border} rounded-xl p-6`}>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`flex-shrink-0 w-20 h-20 ${DEPTH_LEVEL_COLORS[decision.determinedLevel].badge} rounded-xl flex items-center justify-center`}>
|
||||
<span className={`text-3xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text}`}>
|
||||
{decision.determinedLevel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-2xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text} mb-2`}>
|
||||
{DEPTH_LEVEL_LABELS[decision.determinedLevel]}
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">{DEPTH_LEVEL_DESCRIPTIONS[decision.determinedLevel]}</p>
|
||||
{decision.reasoning && decision.reasoning.length > 0 && (
|
||||
<p className="text-sm text-gray-600 italic">{decision.reasoning.map(r => r.description).filter(Boolean).join('. ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ScoreBreakdown
|
||||
// =============================================================================
|
||||
|
||||
export function ScoreBreakdown({ decision }: { decision: ScopeDecision }) {
|
||||
if (!decision.scores) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Analyse</h3>
|
||||
<div className="space-y-4">
|
||||
<ScoreBar label="Risiko-Score" score={decision.scores.risk_score} />
|
||||
<ScoreBar label="Komplexitäts-Score" score={decision.scores.complexity_score} />
|
||||
<ScoreBar label="Assurance-Score" score={decision.scores.assurance_need} />
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<ScoreBar label="Gesamt-Score" score={decision.scores.composite_score} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RegulationsPanel
|
||||
// =============================================================================
|
||||
|
||||
interface RegulationsPanelProps {
|
||||
applicableRegulations?: ApplicableRegulation[]
|
||||
supervisoryAuthorities?: SupervisoryAuthorityInfo[]
|
||||
regulationAssessmentLoading?: boolean
|
||||
onGoToObligations?: () => void
|
||||
}
|
||||
|
||||
export function RegulationsPanel({
|
||||
applicableRegulations,
|
||||
supervisoryAuthorities,
|
||||
regulationAssessmentLoading,
|
||||
onGoToObligations,
|
||||
}: RegulationsPanelProps) {
|
||||
if (!applicableRegulations && !regulationAssessmentLoading) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Anwendbare Regulierungen</h3>
|
||||
{regulationAssessmentLoading ? (
|
||||
<div className="flex items-center gap-3 text-gray-500">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span>Regulierungen werden geprueft...</span>
|
||||
</div>
|
||||
) : applicableRegulations && applicableRegulations.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{applicableRegulations.map((reg) => (
|
||||
<div key={reg.id} className="flex items-center justify-between border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900">{reg.name}</span>
|
||||
{reg.classification && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{reg.classification}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-600">
|
||||
<span>{reg.obligation_count} Pflichten</span>
|
||||
{reg.control_count > 0 && <span className="ml-2">{reg.control_count} Controls</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{supervisoryAuthorities && supervisoryAuthorities.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Zustaendige Aufsichtsbehoerden</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{supervisoryAuthorities.map((sa, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-100 rounded flex items-center justify-center mt-0.5">
|
||||
<svg className="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{sa.authority.abbreviation}</span>
|
||||
<span className="text-xs text-gray-500 ml-1">({sa.domain})</span>
|
||||
<p className="text-xs text-gray-600 mt-0.5">{sa.authority.name}</p>
|
||||
{sa.authority.url && (
|
||||
<a href={sa.authority.url} target="_blank" rel="noopener noreferrer" className="text-xs text-purple-600 hover:text-purple-700">
|
||||
Website →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onGoToObligations && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onGoToObligations}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Pflichten anzeigen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Keine anwendbaren Regulierungen ermittelt.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HardTriggersPanel
|
||||
// =============================================================================
|
||||
|
||||
export function HardTriggersPanel({
|
||||
decision,
|
||||
expandedTrigger,
|
||||
onToggle,
|
||||
}: {
|
||||
decision: ScopeDecision
|
||||
expandedTrigger: number | null
|
||||
onToggle: (idx: number) => void
|
||||
}) {
|
||||
if (!decision.triggeredHardTriggers || decision.triggeredHardTriggers.length === 0) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Hard-Trigger</h3>
|
||||
<div className="space-y-3">
|
||||
{decision.triggeredHardTriggers.map((trigger, idx) => (
|
||||
<div key={idx} className="border rounded-lg overflow-hidden border-red-300 bg-red-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(idx)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-opacity-80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
||||
<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">Min. {trigger.minimumLevel}</span>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 text-gray-500 transition-transform ${expandedTrigger === idx ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expandedTrigger === idx && (
|
||||
<div className="px-4 pb-4 pt-2 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-700 mb-2">{trigger.description}</p>
|
||||
{trigger.legalReference && (
|
||||
<p className="text-xs text-gray-600 mb-2"><span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}</p>
|
||||
)}
|
||||
{trigger.mandatoryDocuments && trigger.mandatoryDocuments.length > 0 && (
|
||||
<p className="text-xs text-gray-700"><span className="font-medium">Pflichtdokumente:</span> {trigger.mandatoryDocuments.join(', ')}</p>
|
||||
)}
|
||||
{trigger.requiresDSFA && (
|
||||
<p className="text-xs text-orange-700 font-medium mt-1">DSFA erforderlich</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RequiredDocumentsPanel
|
||||
// =============================================================================
|
||||
|
||||
export function RequiredDocumentsPanel({ decision }: { decision: ScopeDecision }) {
|
||||
if (!decision.requiredDocuments || decision.requiredDocuments.length === 0) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Erforderliche Dokumente</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<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">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">Trigger</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{decision.requiredDocuments.map((doc, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType}</span>
|
||||
{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">Pflicht</span>
|
||||
)}
|
||||
</div>
|
||||
</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">{doc.estimatedEffort ? `${doc.estimatedEffort}h` : '-'}</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.triggeredBy && doc.triggeredBy.length > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">{doc.triggeredBy.join(', ')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.sdkStepUrl && (
|
||||
<a href={doc.sdkStepUrl} className="text-sm text-purple-600 hover:text-purple-700 font-medium">Zum SDK-Schritt →</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RiskFlagsPanel
|
||||
// =============================================================================
|
||||
|
||||
export function RiskFlagsPanel({ decision }: { decision: ScopeDecision }) {
|
||||
if (!decision.riskFlags || decision.riskFlags.length === 0) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Risiko-Flags</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.riskFlags.map((flag, idx) => (
|
||||
<div key={idx} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{flag.message}</h4>
|
||||
{getSeverityBadge(flag.severity)}
|
||||
</div>
|
||||
{flag.legalReference && <p className="text-xs text-gray-500 mb-2">{flag.legalReference}</p>}
|
||||
<p className="text-sm text-gray-600"><span className="font-medium">Empfehlung:</span> {flag.recommendation}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GapAnalysisPanel
|
||||
// =============================================================================
|
||||
|
||||
export function GapAnalysisPanel({ decision }: { decision: ScopeDecision }) {
|
||||
if (!decision.gaps || decision.gaps.length === 0) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gap-Analyse</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.gaps.map((gap, idx) => (
|
||||
<div key={idx} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{gap.description}</h4>
|
||||
{getSeverityBadge(gap.severity)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-2"><span className="font-medium">Ist:</span> {gap.currentState}</p>
|
||||
<p className="text-sm text-gray-600 mb-2"><span className="font-medium">Soll:</span> {gap.targetState}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>Aufwand: ~{gap.effort}h</span>
|
||||
<span>Level: {gap.requiredFor}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NextActionsPanel
|
||||
// =============================================================================
|
||||
|
||||
export function NextActionsPanel({ decision }: { decision: ScopeDecision }) {
|
||||
if (!decision.nextActions || decision.nextActions.length === 0) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nächste Schritte</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.nextActions.map((action, idx) => (
|
||||
<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">
|
||||
<span className="text-sm font-bold text-purple-700">{idx + 1}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">{action.title}</h4>
|
||||
<p className="text-sm text-gray-700 mb-2">{action.description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{action.estimatedEffort > 0 && (
|
||||
<span className="text-xs text-gray-600"><span className="font-medium">Aufwand:</span> ~{action.estimatedEffort}h</span>
|
||||
)}
|
||||
{action.sdkStepUrl && (
|
||||
<a href={action.sdkStepUrl} className="text-xs text-purple-600 hover:text-purple-700">Zum SDK-Schritt →</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AuditTrailPanel
|
||||
// =============================================================================
|
||||
|
||||
export function AuditTrailPanel({
|
||||
decision,
|
||||
showAuditTrail,
|
||||
onToggle,
|
||||
}: {
|
||||
decision: ScopeDecision
|
||||
showAuditTrail: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
if (!decision.reasoning || decision.reasoning.length === 0) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<button type="button" onClick={onToggle} className="w-full flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Audit-Trail</h3>
|
||||
<svg className={`w-5 h-5 text-gray-500 transition-transform ${showAuditTrail ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showAuditTrail && (
|
||||
<div className="space-y-3">
|
||||
{decision.reasoning.map((entry, idx) => (
|
||||
<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>
|
||||
<p className="text-sm text-gray-700 mb-2">{entry.description}</p>
|
||||
{entry.factors && entry.factors.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 space-y-1">
|
||||
{entry.factors.map((factor, factorIdx) => <li key={factorIdx}>• {factor}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{entry.impact && <p className="text-xs text-purple-700 font-medium mt-1">{entry.impact}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import type { ScopeDecision, ComplianceDepthLevel, ApplicableRegulation, SupervisoryAuthorityInfo } from '@/lib/sdk/compliance-scope-types'
|
||||
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
|
||||
import type { ScopeDecision, ApplicableRegulation, SupervisoryAuthorityInfo } from '@/lib/sdk/compliance-scope-types'
|
||||
import {
|
||||
LevelCard,
|
||||
ScoreBreakdown,
|
||||
RegulationsPanel,
|
||||
HardTriggersPanel,
|
||||
RequiredDocumentsPanel,
|
||||
RiskFlagsPanel,
|
||||
GapAnalysisPanel,
|
||||
NextActionsPanel,
|
||||
AuditTrailPanel,
|
||||
} from './ScopeDecisionSections'
|
||||
|
||||
interface ScopeDecisionTabProps {
|
||||
decision: ScopeDecision | null
|
||||
@@ -51,390 +61,32 @@ export function ScopeDecisionTab({
|
||||
)
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 80) return 'from-red-500 to-red-600'
|
||||
if (score >= 60) return 'from-orange-500 to-orange-600'
|
||||
if (score >= 40) return 'from-yellow-500 to-yellow-600'
|
||||
return 'from-green-500 to-green-600'
|
||||
}
|
||||
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
const s = severity.toLowerCase()
|
||||
const colors: Record<string, string> = {
|
||||
low: 'bg-gray-100 text-gray-800',
|
||||
medium: 'bg-yellow-100 text-yellow-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
low: 'Niedrig',
|
||||
medium: 'Mittel',
|
||||
high: 'Hoch',
|
||||
critical: 'Kritisch',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[s] || colors.medium}`}>
|
||||
{labels[s] || severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderScoreBar = (label: string, score: number | undefined) => {
|
||||
const value = score ?? 0
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-gray-700">{label}</span>
|
||||
<span className="text-sm font-bold text-gray-900">{value}/100</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${getScoreColor(value)} transition-all duration-500`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Level Determination */}
|
||||
<div className={`${DEPTH_LEVEL_COLORS[decision.determinedLevel].bg} border-2 ${DEPTH_LEVEL_COLORS[decision.determinedLevel].border} rounded-xl p-6`}>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`flex-shrink-0 w-20 h-20 ${DEPTH_LEVEL_COLORS[decision.determinedLevel].badge} rounded-xl flex items-center justify-center`}>
|
||||
<span className={`text-3xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text}`}>
|
||||
{decision.determinedLevel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-2xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text} mb-2`}>
|
||||
{DEPTH_LEVEL_LABELS[decision.determinedLevel]}
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">{DEPTH_LEVEL_DESCRIPTIONS[decision.determinedLevel]}</p>
|
||||
{decision.reasoning && decision.reasoning.length > 0 && (
|
||||
<p className="text-sm text-gray-600 italic">{decision.reasoning.map(r => r.description).filter(Boolean).join('. ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LevelCard decision={decision} />
|
||||
|
||||
{/* Score Breakdown */}
|
||||
{decision.scores && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Analyse</h3>
|
||||
<div className="space-y-4">
|
||||
{renderScoreBar('Risiko-Score', decision.scores.risk_score)}
|
||||
{renderScoreBar('Komplexitäts-Score', decision.scores.complexity_score)}
|
||||
{renderScoreBar('Assurance-Score', decision.scores.assurance_need)}
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
{renderScoreBar('Gesamt-Score', decision.scores.composite_score)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ScoreBreakdown decision={decision} />
|
||||
|
||||
{/* Applicable Regulations */}
|
||||
{(applicableRegulations || regulationAssessmentLoading) && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Anwendbare Regulierungen</h3>
|
||||
{regulationAssessmentLoading ? (
|
||||
<div className="flex items-center gap-3 text-gray-500">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span>Regulierungen werden geprueft...</span>
|
||||
</div>
|
||||
) : applicableRegulations && applicableRegulations.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{applicableRegulations.map((reg) => (
|
||||
<div
|
||||
key={reg.id}
|
||||
className="flex items-center justify-between border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-900">{reg.name}</span>
|
||||
{reg.classification && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{reg.classification}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-600">
|
||||
<span>{reg.obligation_count} Pflichten</span>
|
||||
{reg.control_count > 0 && (
|
||||
<span className="ml-2">{reg.control_count} Controls</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<RegulationsPanel
|
||||
applicableRegulations={applicableRegulations}
|
||||
supervisoryAuthorities={supervisoryAuthorities}
|
||||
regulationAssessmentLoading={regulationAssessmentLoading}
|
||||
onGoToObligations={onGoToObligations}
|
||||
/>
|
||||
|
||||
{/* Supervisory Authorities */}
|
||||
{supervisoryAuthorities && supervisoryAuthorities.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Zustaendige Aufsichtsbehoerden</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{supervisoryAuthorities.map((sa, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex-shrink-0 w-6 h-6 bg-blue-100 rounded flex items-center justify-center mt-0.5">
|
||||
<svg className="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{sa.authority.abbreviation}</span>
|
||||
<span className="text-xs text-gray-500 ml-1">({sa.domain})</span>
|
||||
<p className="text-xs text-gray-600 mt-0.5">{sa.authority.name}</p>
|
||||
{sa.authority.url && (
|
||||
<a
|
||||
href={sa.authority.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
Website →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HardTriggersPanel
|
||||
decision={decision}
|
||||
expandedTrigger={expandedTrigger}
|
||||
onToggle={(idx) => setExpandedTrigger(expandedTrigger === idx ? null : idx)}
|
||||
/>
|
||||
|
||||
{/* Link to Obligations */}
|
||||
{onGoToObligations && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onGoToObligations}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Pflichten anzeigen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Keine anwendbaren Regulierungen ermittelt.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<RequiredDocumentsPanel decision={decision} />
|
||||
|
||||
{/* Hard Triggers */}
|
||||
{decision.triggeredHardTriggers && decision.triggeredHardTriggers.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Hard-Trigger</h3>
|
||||
<div className="space-y-3">
|
||||
{decision.triggeredHardTriggers.map((trigger, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border rounded-lg overflow-hidden border-red-300 bg-red-50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedTrigger(expandedTrigger === idx ? null : idx)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-opacity-80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
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>
|
||||
<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">
|
||||
Min. {trigger.minimumLevel}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-500 transition-transform ${
|
||||
expandedTrigger === idx ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expandedTrigger === idx && (
|
||||
<div className="px-4 pb-4 pt-2 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-700 mb-2">{trigger.description}</p>
|
||||
{trigger.legalReference && (
|
||||
<p className="text-xs text-gray-600 mb-2">
|
||||
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
|
||||
</p>
|
||||
)}
|
||||
{trigger.mandatoryDocuments && trigger.mandatoryDocuments.length > 0 && (
|
||||
<p className="text-xs text-gray-700">
|
||||
<span className="font-medium">Pflichtdokumente:</span> {trigger.mandatoryDocuments.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{trigger.requiresDSFA && (
|
||||
<p className="text-xs text-orange-700 font-medium mt-1">DSFA erforderlich</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<RiskFlagsPanel decision={decision} />
|
||||
|
||||
{/* Required Documents */}
|
||||
{decision.requiredDocuments && decision.requiredDocuments.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Erforderliche Dokumente</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<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">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">Trigger</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{decision.requiredDocuments.map((doc, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">
|
||||
{DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType}
|
||||
</span>
|
||||
{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">
|
||||
Pflicht
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
{doc.estimatedEffort ? `${doc.estimatedEffort}h` : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.triggeredBy && doc.triggeredBy.length > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{doc.triggeredBy.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.sdkStepUrl && (
|
||||
<a
|
||||
href={doc.sdkStepUrl}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Zum SDK-Schritt →
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<GapAnalysisPanel decision={decision} />
|
||||
|
||||
{/* Risk Flags */}
|
||||
{decision.riskFlags && decision.riskFlags.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Risiko-Flags</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.riskFlags.map((flag, idx) => (
|
||||
<div key={idx} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{flag.message}</h4>
|
||||
{getSeverityBadge(flag.severity)}
|
||||
</div>
|
||||
{flag.legalReference && (
|
||||
<p className="text-xs text-gray-500 mb-2">{flag.legalReference}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Empfehlung:</span> {flag.recommendation}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap Analysis */}
|
||||
{decision.gaps && decision.gaps.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gap-Analyse</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.gaps.map((gap, idx) => (
|
||||
<div key={idx} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{gap.description}</h4>
|
||||
{getSeverityBadge(gap.severity)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
<span className="font-medium">Ist:</span> {gap.currentState}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Soll:</span> {gap.targetState}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>Aufwand: ~{gap.effort}h</span>
|
||||
<span>Level: {gap.requiredFor}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Actions */}
|
||||
{decision.nextActions && decision.nextActions.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nächste Schritte</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.nextActions.map((action, idx) => (
|
||||
<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">
|
||||
<span className="text-sm font-bold text-purple-700">{idx + 1}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">{action.title}</h4>
|
||||
<p className="text-sm text-gray-700 mb-2">{action.description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{action.estimatedEffort > 0 && (
|
||||
<span className="text-xs text-gray-600">
|
||||
<span className="font-medium">Aufwand:</span> ~{action.estimatedEffort}h
|
||||
</span>
|
||||
)}
|
||||
{action.sdkStepUrl && (
|
||||
<a href={action.sdkStepUrl} className="text-xs text-purple-600 hover:text-purple-700">
|
||||
Zum SDK-Schritt →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<NextActionsPanel decision={decision} />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -465,46 +117,11 @@ export function ScopeDecisionTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audit Trail (from reasoning) */}
|
||||
{decision.reasoning && decision.reasoning.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAuditTrail(!showAuditTrail)}
|
||||
className="w-full flex items-center justify-between mb-4"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Audit-Trail</h3>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-500 transition-transform ${showAuditTrail ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{showAuditTrail && (
|
||||
<div className="space-y-3">
|
||||
{decision.reasoning.map((entry, idx) => (
|
||||
<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>
|
||||
<p className="text-sm text-gray-700 mb-2">{entry.description}</p>
|
||||
{entry.factors && entry.factors.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 space-y-1">
|
||||
{entry.factors.map((factor, factorIdx) => (
|
||||
<li key={factorIdx}>• {factor}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{entry.impact && (
|
||||
<p className="text-xs text-purple-700 font-medium mt-1">{entry.impact}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AuditTrailPanel
|
||||
decision={decision}
|
||||
showAuditTrail={showAuditTrail}
|
||||
onToggle={() => setShowAuditTrail(!showAuditTrail)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
|
||||
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
|
||||
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'
|
||||
import { DatenkategorienBlock9 } from './DatenkategorienBlock'
|
||||
import { ScopeQuestionRenderer } from './ScopeQuestionRenderer'
|
||||
|
||||
interface ScopeWizardTabProps {
|
||||
answers: ScopeProfilingAnswer[]
|
||||
@@ -102,12 +101,107 @@ export function ScopeWizardTab({
|
||||
const toggleHelp = useCallback((questionId: string) => {
|
||||
setExpandedHelp(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(questionId)) next.delete(questionId)
|
||||
else next.add(questionId)
|
||||
if (next.has(questionId)) { next.delete(questionId) } else { next.add(questionId) }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
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)
|
||||
const label = (
|
||||
<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>
|
||||
)
|
||||
|
||||
switch (question.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">{label}</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">
|
||||
{label}
|
||||
<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">
|
||||
{label}
|
||||
<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">
|
||||
{label}
|
||||
<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">
|
||||
{label}
|
||||
<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 */}
|
||||
@@ -126,25 +220,12 @@ export function ScopeWizardTab({
|
||||
const optionalDone = !hasRequired && hasAnyAnswer
|
||||
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<span className={`text-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>{block.title}</span>
|
||||
{allRequiredDone || optionalDone ? (
|
||||
<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>
|
||||
<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>
|
||||
{!hasRequired && <span>(optional)</span>}
|
||||
</span>
|
||||
) : !hasRequired ? (
|
||||
@@ -154,12 +235,7 @@ export function ScopeWizardTab({
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
allRequiredDone || optionalDone ? 'bg-green-500' : !hasRequired ? 'bg-gray-300' : 'bg-orange-400'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className={`h-full transition-all ${allRequiredDone || optionalDone ? 'bg-green-500' : !hasRequired ? 'bg-gray-300' : 'bg-orange-400'}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -175,20 +251,15 @@ export function ScopeWizardTab({
|
||||
<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 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 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 */}
|
||||
{/* Unanswered required questions summary */}
|
||||
{(() => {
|
||||
const allUnanswered = getUnansweredRequiredQuestions(answers)
|
||||
if (allUnanswered.length === 0) return null
|
||||
@@ -206,11 +277,7 @@ export function ScopeWizardTab({
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
@@ -228,17 +295,13 @@ export function ScopeWizardTab({
|
||||
<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"
|
||||
>
|
||||
<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 */}
|
||||
{/* Profile Info Box */}
|
||||
{companyProfile && (() => {
|
||||
const profileItems = getProfileInfoForBlock(companyProfile, currentBlock.id as ScopeQuestionBlockId)
|
||||
if (profileItems.length === 0) return null
|
||||
@@ -247,27 +310,16 @@ export function ScopeWizardTab({
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
@@ -281,9 +333,7 @@ export function ScopeWizardTab({
|
||||
) : (
|
||||
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'
|
||||
: ''
|
||||
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}`}>
|
||||
<ScopeQuestionRenderer
|
||||
@@ -303,32 +353,16 @@ export function ScopeWizardTab({
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user