Each page.tsx was >1000 LOC; extract components to _components/ and hooks to _hooks/ so page files stay under 500 LOC (164 / 255 / 243 respectively). Zero behavior changes — logic relocated verbatim. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
254 lines
12 KiB
TypeScript
254 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { ConfidenceLevelBadge } from '../../evidence/components/anti-fake-badges'
|
|
import type { TraceabilityMatrixData } from './types'
|
|
import { DOMAIN_LABELS } from './types'
|
|
|
|
interface TraceabilityTabProps {
|
|
traceabilityMatrix: TraceabilityMatrixData | null
|
|
traceabilityLoading: boolean
|
|
traceabilityFilter: 'all' | 'covered' | 'uncovered' | 'fully_verified'
|
|
setTraceabilityFilter: (f: 'all' | 'covered' | 'uncovered' | 'fully_verified') => void
|
|
traceabilityDomainFilter: string
|
|
setTraceabilityDomainFilter: (d: string) => void
|
|
expandedControls: Set<string>
|
|
expandedEvidence: Set<string>
|
|
toggleControlExpanded: (id: string) => void
|
|
toggleEvidenceExpanded: (id: string) => void
|
|
}
|
|
|
|
export function TraceabilityTab({
|
|
traceabilityMatrix, traceabilityLoading,
|
|
traceabilityFilter, setTraceabilityFilter,
|
|
traceabilityDomainFilter, setTraceabilityDomainFilter,
|
|
expandedControls, expandedEvidence,
|
|
toggleControlExpanded, toggleEvidenceExpanded,
|
|
}: TraceabilityTabProps) {
|
|
if (traceabilityLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
<span className="ml-3 text-slate-500">Traceability Matrix wird geladen...</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!traceabilityMatrix) {
|
|
return (
|
|
<div className="text-center py-12 text-slate-500">
|
|
Keine Daten verfuegbar. Stellen Sie sicher, dass Controls und Evidence vorhanden sind.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const summary = traceabilityMatrix.summary
|
|
const totalControls = summary.total_controls || 0
|
|
const covered = summary.covered || 0
|
|
const fullyVerified = summary.fully_verified || 0
|
|
const uncovered = summary.uncovered || 0
|
|
|
|
const filteredControls = (traceabilityMatrix.controls || []).filter(ctrl => {
|
|
if (traceabilityFilter === 'covered' && !ctrl.coverage.has_evidence) return false
|
|
if (traceabilityFilter === 'uncovered' && ctrl.coverage.has_evidence) return false
|
|
if (traceabilityFilter === 'fully_verified' && !ctrl.coverage.all_assertions_verified) return false
|
|
if (traceabilityDomainFilter !== 'all' && ctrl.domain !== traceabilityDomainFilter) return false
|
|
return true
|
|
})
|
|
|
|
const domains = [...new Set(traceabilityMatrix.controls.map(c => c.domain))].sort()
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-purple-700">{totalControls}</div>
|
|
<div className="text-sm text-purple-600">Total Controls</div>
|
|
</div>
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-blue-700">{covered}</div>
|
|
<div className="text-sm text-blue-600">Abgedeckt</div>
|
|
</div>
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-green-700">{fullyVerified}</div>
|
|
<div className="text-sm text-green-600">Vollst. verifiziert</div>
|
|
</div>
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-red-700">{uncovered}</div>
|
|
<div className="text-sm text-red-600">Unabgedeckt</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter Bar */}
|
|
<div className="flex flex-wrap gap-4 items-center">
|
|
<div className="flex gap-1">
|
|
{([
|
|
{ key: 'all', label: 'Alle' },
|
|
{ key: 'covered', label: 'Abgedeckt' },
|
|
{ key: 'uncovered', label: 'Nicht abgedeckt' },
|
|
{ key: 'fully_verified', label: 'Vollst. verifiziert' },
|
|
] as const).map(f => (
|
|
<button
|
|
key={f.key}
|
|
onClick={() => setTraceabilityFilter(f.key)}
|
|
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
|
traceabilityFilter === f.key
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
{f.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="h-4 w-px bg-slate-300" />
|
|
<div className="flex gap-1 flex-wrap">
|
|
<button
|
|
onClick={() => setTraceabilityDomainFilter('all')}
|
|
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
|
traceabilityDomainFilter === 'all'
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
Alle Domains
|
|
</button>
|
|
{domains.map(d => (
|
|
<button
|
|
key={d}
|
|
onClick={() => setTraceabilityDomainFilter(d)}
|
|
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
|
traceabilityDomainFilter === d
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
{DOMAIN_LABELS[d] || d}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls List */}
|
|
<div className="space-y-2">
|
|
{filteredControls.length === 0 ? (
|
|
<div className="text-center py-8 text-slate-400">
|
|
Keine Controls fuer diesen Filter gefunden.
|
|
</div>
|
|
) : filteredControls.map(ctrl => {
|
|
const isExpanded = expandedControls.has(ctrl.id)
|
|
const coverageIcon = ctrl.coverage.all_assertions_verified
|
|
? { symbol: '\u2713', color: 'text-green-600 bg-green-50' }
|
|
: ctrl.coverage.has_evidence
|
|
? { symbol: '\u25D0', color: 'text-yellow-600 bg-yellow-50' }
|
|
: { symbol: '\u2717', color: 'text-red-600 bg-red-50' }
|
|
|
|
return (
|
|
<div key={ctrl.id} className="border rounded-lg overflow-hidden">
|
|
{/* Control Row */}
|
|
<button
|
|
onClick={() => toggleControlExpanded(ctrl.id)}
|
|
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-slate-50 transition-colors"
|
|
>
|
|
<span className="text-slate-400 text-xs">{isExpanded ? '\u25BC' : '\u25B6'}</span>
|
|
<span className={`w-7 h-7 flex items-center justify-center rounded-full text-sm font-medium ${coverageIcon.color}`}>
|
|
{coverageIcon.symbol}
|
|
</span>
|
|
<code className="text-xs bg-slate-100 px-2 py-0.5 rounded text-slate-600 font-mono">{ctrl.control_id}</code>
|
|
<span className="text-sm text-slate-800 flex-1 truncate">{ctrl.title}</span>
|
|
<span className="text-xs bg-slate-100 text-slate-500 px-2 py-0.5 rounded">{DOMAIN_LABELS[ctrl.domain] || ctrl.domain}</span>
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
ctrl.status === 'implemented' ? 'bg-green-100 text-green-700'
|
|
: ctrl.status === 'in_progress' ? 'bg-blue-100 text-blue-700'
|
|
: 'bg-slate-100 text-slate-600'
|
|
}`}>
|
|
{ctrl.status}
|
|
</span>
|
|
<ConfidenceLevelBadge level={ctrl.coverage.min_confidence_level} />
|
|
<span className="text-xs text-slate-400 min-w-[3rem] text-right">
|
|
{ctrl.evidence.length} Ev.
|
|
</span>
|
|
</button>
|
|
|
|
{/* Expanded: Evidence list */}
|
|
{isExpanded && (
|
|
<div className="border-t bg-slate-50">
|
|
{ctrl.evidence.length === 0 ? (
|
|
<div className="px-8 py-3 text-xs text-slate-400 italic">
|
|
Kein Evidence verknuepft.
|
|
</div>
|
|
) : ctrl.evidence.map(ev => {
|
|
const evExpanded = expandedEvidence.has(ev.id)
|
|
return (
|
|
<div key={ev.id} className="border-b last:border-b-0">
|
|
<button
|
|
onClick={() => toggleEvidenceExpanded(ev.id)}
|
|
className="w-full flex items-center gap-3 px-8 py-2 text-left hover:bg-slate-100 transition-colors"
|
|
>
|
|
<span className="text-slate-400 text-xs">{evExpanded ? '\u25BC' : '\u25B6'}</span>
|
|
<span className="text-sm text-slate-700 flex-1 truncate">{ev.title}</span>
|
|
<span className="text-xs bg-white border px-2 py-0.5 rounded text-slate-500">{ev.evidence_type}</span>
|
|
<ConfidenceLevelBadge level={ev.confidence_level} />
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
ev.status === 'valid' ? 'bg-green-100 text-green-700'
|
|
: ev.status === 'expired' ? 'bg-red-100 text-red-700'
|
|
: 'bg-slate-100 text-slate-600'
|
|
}`}>
|
|
{ev.status}
|
|
</span>
|
|
<span className="text-xs text-slate-400 min-w-[3rem] text-right">
|
|
{ev.assertions.length} Ass.
|
|
</span>
|
|
</button>
|
|
|
|
{/* Expanded: Assertions list */}
|
|
{evExpanded && ev.assertions.length > 0 && (
|
|
<div className="bg-white border-t">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-slate-50">
|
|
<tr>
|
|
<th className="px-12 py-1.5 text-left text-slate-500 font-medium">Aussage</th>
|
|
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-20">Typ</th>
|
|
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-24">Konfidenz</th>
|
|
<th className="px-3 py-1.5 text-center text-slate-500 font-medium w-16">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{ev.assertions.map(a => (
|
|
<tr key={a.id} className="hover:bg-slate-50">
|
|
<td className="px-12 py-1.5 text-slate-700">{a.sentence_text}</td>
|
|
<td className="px-3 py-1.5 text-center text-slate-500">{a.assertion_type}</td>
|
|
<td className="px-3 py-1.5 text-center">
|
|
<span className={`font-medium ${
|
|
a.confidence >= 0.8 ? 'text-green-600'
|
|
: a.confidence >= 0.5 ? 'text-yellow-600'
|
|
: 'text-red-600'
|
|
}`}>
|
|
{(a.confidence * 100).toFixed(0)}%
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-1.5 text-center">
|
|
{a.verified
|
|
? <span className="text-green-600 font-medium">{'\u2713'}</span>
|
|
: <span className="text-slate-400">{'\u2717'}</span>
|
|
}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|