Each page.tsx exceeded the 500-LOC hard cap. Extracted components and hooks into colocated _components/ and _hooks/ directories; page.tsx is now a thin orchestrator. - controls/page.tsx: 944 → 180 LOC; extracted ControlCard, AddControlForm, LoadingSkeleton, TransitionErrorBanner, StatsCards, FilterBar, RAGPanel into _components/ and useControlsData, useRAGSuggestions into _hooks/; types into _types.ts - training/page.tsx: 780 → 288 LOC; extracted ContentTab (inline content generator tab) into _components/ContentTab.tsx - control-provenance/page.tsx: 739 → 122 LOC; extracted MarkdownRenderer, UsageBadge, PermBadge, LicenseMatrix, SourceRegistry into _components/; PROVENANCE_SECTIONS static data into _data/provenance-sections.ts - iace/[projectId]/verification/page.tsx: 673 → 196 LOC; extracted StatusBadge, VerificationForm, CompleteModal, SuggestEvidenceModal, VerificationTable into _components/ Zero behavior changes; logic relocated verbatim. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
164 lines
6.8 KiB
TypeScript
164 lines
6.8 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import type { DisplayControl, DisplayControlType, DisplayCategory, DisplayStatus } from '../_types'
|
|
import type { ImplementationStatus } from '@/lib/sdk'
|
|
|
|
const TYPE_COLORS: Record<DisplayControlType, string> = {
|
|
preventive: 'bg-blue-100 text-blue-700',
|
|
detective: 'bg-purple-100 text-purple-700',
|
|
corrective: 'bg-orange-100 text-orange-700',
|
|
}
|
|
|
|
const CATEGORY_COLORS: Record<DisplayCategory, string> = {
|
|
technical: 'bg-green-100 text-green-700',
|
|
organizational: 'bg-yellow-100 text-yellow-700',
|
|
physical: 'bg-gray-100 text-gray-700',
|
|
}
|
|
|
|
const STATUS_COLORS: Record<DisplayStatus, string> = {
|
|
implemented: 'border-green-200 bg-green-50',
|
|
partial: 'border-yellow-200 bg-yellow-50',
|
|
planned: 'border-blue-200 bg-blue-50',
|
|
'not-implemented': 'border-red-200 bg-red-50',
|
|
}
|
|
|
|
const STATUS_LABELS: Record<DisplayStatus, string> = {
|
|
implemented: 'Implementiert',
|
|
partial: 'Teilweise',
|
|
planned: 'Geplant',
|
|
'not-implemented': 'Nicht implementiert',
|
|
}
|
|
|
|
export function ControlCard({
|
|
control,
|
|
onStatusChange,
|
|
onEffectivenessChange,
|
|
onLinkEvidence,
|
|
}: {
|
|
control: DisplayControl
|
|
onStatusChange: (status: ImplementationStatus) => void
|
|
onEffectivenessChange: (effectivenessPercent: number) => void
|
|
onLinkEvidence: () => void
|
|
}) {
|
|
const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false)
|
|
|
|
return (
|
|
<div className={`bg-white rounded-xl border-2 p-6 ${STATUS_COLORS[control.displayStatus]}`}>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">{control.code}</span>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${TYPE_COLORS[control.displayType]}`}>
|
|
{control.displayType === 'preventive' ? 'Praeventiv' :
|
|
control.displayType === 'detective' ? 'Detektiv' : 'Korrektiv'}
|
|
</span>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${CATEGORY_COLORS[control.displayCategory]}`}>
|
|
{control.displayCategory === 'technical' ? 'Technisch' :
|
|
control.displayCategory === 'organizational' ? 'Organisatorisch' : 'Physisch'}
|
|
</span>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900">{control.name}</h3>
|
|
<p className="text-sm text-gray-500 mt-1">{control.description}</p>
|
|
</div>
|
|
<select
|
|
value={control.implementationStatus}
|
|
onChange={(e) => onStatusChange(e.target.value as ImplementationStatus)}
|
|
className={`px-3 py-1 text-sm rounded-full border ${STATUS_COLORS[control.displayStatus]}`}
|
|
>
|
|
<option value="NOT_IMPLEMENTED">Nicht implementiert</option>
|
|
<option value="PARTIAL">Teilweise</option>
|
|
<option value="IMPLEMENTED">Implementiert</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<div
|
|
className="flex items-center justify-between text-sm mb-1 cursor-pointer"
|
|
onClick={() => setShowEffectivenessSlider(!showEffectivenessSlider)}
|
|
>
|
|
<span className="text-gray-500">Wirksamkeit</span>
|
|
<span className="font-medium">{control.effectivenessPercent}%</span>
|
|
</div>
|
|
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${
|
|
control.effectivenessPercent >= 80 ? 'bg-green-500' :
|
|
control.effectivenessPercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
|
}`}
|
|
style={{ width: `${control.effectivenessPercent}%` }}
|
|
/>
|
|
</div>
|
|
{showEffectivenessSlider && (
|
|
<div className="mt-2">
|
|
<input
|
|
type="range" min={0} max={100} value={control.effectivenessPercent}
|
|
onChange={(e) => onEffectivenessChange(Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
|
<div className="text-gray-500">
|
|
<span>Verantwortlich: </span>
|
|
<span className="font-medium text-gray-700">{control.owner || 'Nicht zugewiesen'}</span>
|
|
</div>
|
|
<div className="text-gray-500">Letzte Pruefung: {control.lastReview.toLocaleDateString('de-DE')}</div>
|
|
</div>
|
|
|
|
<div className="mt-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{control.linkedRequirements.slice(0, 3).map(req => (
|
|
<span key={req} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{req}</span>
|
|
))}
|
|
{control.linkedRequirements.length > 3 && (
|
|
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">+{control.linkedRequirements.length - 3}</span>
|
|
)}
|
|
</div>
|
|
<span className={`px-3 py-1 text-xs rounded-full ${
|
|
control.displayStatus === 'implemented' ? 'bg-green-100 text-green-700' :
|
|
control.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
|
|
control.displayStatus === 'planned' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
|
}`}>
|
|
{STATUS_LABELS[control.displayStatus]}
|
|
</span>
|
|
</div>
|
|
|
|
{control.linkedEvidence.length > 0 && (
|
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
<span className="text-xs text-gray-500 mb-1 block">
|
|
Nachweise: {control.linkedEvidence.length}
|
|
{(() => {
|
|
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
|
|
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
|
|
).length
|
|
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
|
|
})()}
|
|
</span>
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
{control.linkedEvidence.map(ev => (
|
|
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
|
|
ev.status === 'valid' ? 'bg-green-50 text-green-700' :
|
|
ev.status === 'expired' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-700'
|
|
}`}>
|
|
{ev.title}
|
|
{(ev as { confidenceLevel?: string }).confidenceLevel && (
|
|
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
<button onClick={onLinkEvidence} className="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
|
Evidence verknuepfen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|