Files
breakpilot-compliance/admin-compliance/app/sdk/evidence/page.tsx
Benjamin Admin 49ce417428
All checks were successful
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 32s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 2s
feat: add compliance modules 2-5 (dashboard, security templates, process manager, evidence collector)
Module 2: Extended Compliance Dashboard with roadmap, module-status, next-actions, snapshots, score-history
Module 3: 7 German security document templates (IT-Sicherheitskonzept, Datenschutz, Backup, Logging, Incident-Response, Zugriff, Risikomanagement)
Module 4: Compliance Process Manager with CRUD, complete/skip/seed, ~50 seed tasks, 3-tab UI
Module 5: Evidence Collector Extended with automated checks, control-mapping, coverage report, 4-tab UI

Also includes: canonical control library enhancements (verification method, categories, dedup), control generator improvements, RAG client extensions

52 tests pass, frontend builds clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:03:04 +01:00

1112 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import React, { useState, useEffect, useRef } from 'react'
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// TYPES
// =============================================================================
type DisplayEvidenceType = 'document' | 'screenshot' | 'log' | 'audit-report' | 'certificate'
type DisplayFormat = 'pdf' | 'image' | 'text' | 'json'
type DisplayStatus = 'valid' | 'expired' | 'pending-review'
interface DisplayEvidence {
id: string
name: string
description: string
displayType: DisplayEvidenceType
format: DisplayFormat
controlId: string
linkedRequirements: string[]
linkedControls: string[]
uploadedBy: string
uploadedAt: Date
validFrom: Date
validUntil: Date | null
status: DisplayStatus
fileSize: string
fileUrl: string | null
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function mapEvidenceTypeToDisplay(type: EvidenceType): DisplayEvidenceType {
switch (type) {
case 'DOCUMENT': return 'document'
case 'SCREENSHOT': return 'screenshot'
case 'LOG': return 'log'
case 'CERTIFICATE': return 'certificate'
case 'AUDIT_REPORT': return 'audit-report'
default: return 'document'
}
}
function getEvidenceStatus(validUntil: Date | null): DisplayStatus {
if (!validUntil) return 'pending-review'
const now = new Date()
if (validUntil < now) return 'expired'
return 'valid'
}
// =============================================================================
// FALLBACK TEMPLATES
// =============================================================================
interface EvidenceTemplate {
id: string
name: string
description: string
type: EvidenceType
displayType: DisplayEvidenceType
format: DisplayFormat
controlId: string
linkedRequirements: string[]
linkedControls: string[]
uploadedBy: string
validityDays: number
fileSize: string
}
const evidenceTemplates: EvidenceTemplate[] = [
{
id: 'ev-dse-001',
name: 'Datenschutzerklaerung v2.3',
description: 'Aktuelle Datenschutzerklaerung fuer Website und App',
type: 'DOCUMENT',
displayType: 'document',
format: 'pdf',
controlId: 'ctrl-org-001',
linkedRequirements: ['req-gdpr-13', 'req-gdpr-14'],
linkedControls: ['ctrl-org-001'],
uploadedBy: 'DSB',
validityDays: 365,
fileSize: '245 KB',
},
{
id: 'ev-pentest-001',
name: 'Penetrationstest Report Q4/2024',
description: 'Externer Penetrationstest durch Security-Partner',
type: 'AUDIT_REPORT',
displayType: 'audit-report',
format: 'pdf',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-gdpr-32', 'req-iso-a12'],
linkedControls: ['ctrl-tom-001', 'ctrl-tom-002', 'ctrl-det-001'],
uploadedBy: 'IT Security Team',
validityDays: 365,
fileSize: '2.1 MB',
},
{
id: 'ev-iso-cert',
name: 'ISO 27001 Zertifikat',
description: 'Zertifizierung des ISMS',
type: 'CERTIFICATE',
displayType: 'certificate',
format: 'pdf',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-iso-4.1', 'req-iso-5.1'],
linkedControls: [],
uploadedBy: 'QM Abteilung',
validityDays: 365,
fileSize: '156 KB',
},
{
id: 'ev-schulung-001',
name: 'Schulungsnachweis Datenschutz 2024',
description: 'Teilnehmerliste und Schulungsinhalt',
type: 'DOCUMENT',
displayType: 'document',
format: 'pdf',
controlId: 'ctrl-org-001',
linkedRequirements: ['req-gdpr-39'],
linkedControls: ['ctrl-org-001'],
uploadedBy: 'HR Team',
validityDays: 365,
fileSize: '890 KB',
},
{
id: 'ev-rbac-001',
name: 'Access Control Screenshot',
description: 'Nachweis der RBAC-Konfiguration',
type: 'SCREENSHOT',
displayType: 'screenshot',
format: 'image',
controlId: 'ctrl-tom-001',
linkedRequirements: ['req-gdpr-32'],
linkedControls: ['ctrl-tom-001'],
uploadedBy: 'Admin',
validityDays: 0,
fileSize: '1.2 MB',
},
{
id: 'ev-log-001',
name: 'Audit Log Export',
description: 'Monatlicher Audit-Log Export',
type: 'LOG',
displayType: 'log',
format: 'json',
controlId: 'ctrl-det-001',
linkedRequirements: ['req-gdpr-32'],
linkedControls: ['ctrl-det-001'],
uploadedBy: 'System',
validityDays: 90,
fileSize: '4.5 MB',
},
]
// =============================================================================
// COMPONENTS
// =============================================================================
function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void }) {
const typeIcons = {
document: (
<svg className="w-6 h-6" 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>
),
screenshot: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
),
log: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
'audit-report': (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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>
),
certificate: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
),
}
const statusColors = {
valid: 'bg-green-100 text-green-700 border-green-200',
expired: 'bg-red-100 text-red-700 border-red-200',
'pending-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
}
const statusLabels = {
valid: 'Gueltig',
expired: 'Abgelaufen',
'pending-review': 'Pruefung ausstehend',
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${
evidence.status === 'expired' ? 'border-red-200' :
evidence.status === 'pending-review' ? 'border-yellow-200' : 'border-gray-200'
}`}>
<div className="flex items-start gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
evidence.displayType === 'certificate' ? 'bg-yellow-100 text-yellow-600' :
evidence.displayType === 'audit-report' ? 'bg-purple-100 text-purple-600' :
evidence.displayType === 'screenshot' ? 'bg-blue-100 text-blue-600' :
evidence.displayType === 'log' ? 'bg-green-100 text-green-600' :
'bg-gray-100 text-gray-600'
}`}>
{typeIcons[evidence.displayType]}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">{evidence.name}</h3>
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
{statusLabels[evidence.status]}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{evidence.description}</p>
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span>Hochgeladen: {evidence.uploadedAt.toLocaleDateString('de-DE')}</span>
{evidence.validUntil && (
<span className={evidence.status === 'expired' ? 'text-red-600' : ''}>
Gueltig bis: {evidence.validUntil.toLocaleDateString('de-DE')}
</span>
)}
<span>{evidence.fileSize}</span>
</div>
<div className="mt-3 flex items-center gap-2 flex-wrap">
{evidence.linkedRequirements.map(req => (
<span key={req} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{req}
</span>
))}
{evidence.linkedControls.map(ctrl => (
<span key={ctrl} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
{ctrl}
</span>
))}
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<span className="text-sm text-gray-500">Hochgeladen von: {evidence.uploadedBy}</span>
<div className="flex items-center gap-2">
<button
onClick={onView}
disabled={!evidence.fileUrl}
className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Anzeigen
</button>
<button
onClick={onDownload}
disabled={!evidence.fileUrl}
className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Herunterladen
</button>
<button
onClick={onDelete}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
</div>
)
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-gray-200 rounded-lg" />
<div className="flex-1">
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded" />
</div>
</div>
</div>
))}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
// =============================================================================
// EVIDENCE CHECK TYPES
// =============================================================================
interface EvidenceCheck {
id: string
check_code: string
title: string
description: string | null
check_type: string
target_url: string | null
frequency: string
is_active: boolean
last_run_at: string | null
next_run_at: string | null
}
interface CheckResult {
id: string
check_id: string
run_status: string
summary: string | null
findings_count: number
critical_findings: number
duration_ms: number
run_at: string
}
interface EvidenceMapping {
id: string
evidence_id: string
control_code: string
mapping_type: string
verified_at: string | null
verified_by: string | null
notes: string | null
}
interface CoverageReport {
total_controls: number
controls_with_evidence: number
controls_without_evidence: number
coverage_percent: number
}
type EvidenceTabKey = 'evidence' | 'checks' | 'mapping' | 'report'
const CHECK_TYPE_LABELS: Record<string, { label: string; color: string }> = {
tls_scan: { label: 'TLS-Scan', color: 'bg-blue-100 text-blue-700' },
header_check: { label: 'Header-Check', color: 'bg-green-100 text-green-700' },
certificate_check: { label: 'Zertifikat', color: 'bg-yellow-100 text-yellow-700' },
dns_check: { label: 'DNS-Check', color: 'bg-purple-100 text-purple-700' },
api_scan: { label: 'API-Scan', color: 'bg-indigo-100 text-indigo-700' },
config_scan: { label: 'Config-Scan', color: 'bg-orange-100 text-orange-700' },
port_scan: { label: 'Port-Scan', color: 'bg-red-100 text-red-700' },
}
const RUN_STATUS_LABELS: Record<string, { label: string; color: string }> = {
running: { label: 'Laeuft...', color: 'bg-blue-100 text-blue-700' },
passed: { label: 'Bestanden', color: 'bg-green-100 text-green-700' },
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
warning: { label: 'Warnung', color: 'bg-yellow-100 text-yellow-700' },
error: { label: 'Fehler', color: 'bg-red-100 text-red-700' },
}
const CHECK_API = '/api/sdk/v1/compliance/evidence-checks'
export default function EvidencePage() {
const { state, dispatch } = useSDK()
const [activeTab, setActiveTab] = useState<EvidenceTabKey>('evidence')
const [filter, setFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
// Evidence Checks state
const [checks, setChecks] = useState<EvidenceCheck[]>([])
const [checksLoading, setChecksLoading] = useState(false)
const [runningCheckId, setRunningCheckId] = useState<string | null>(null)
const [checkResults, setCheckResults] = useState<Record<string, CheckResult[]>>({})
// Mappings state
const [mappings, setMappings] = useState<EvidenceMapping[]>([])
const [coverageReport, setCoverageReport] = useState<CoverageReport | null>(null)
const [seedingChecks, setSeedingChecks] = useState(false)
// Fetch evidence from backend on mount and when page changes
useEffect(() => {
const fetchEvidence = async () => {
try {
setLoading(true)
const res = await fetch(`/api/sdk/v1/compliance/evidence?page=${page}&limit=${pageSize}`)
if (res.ok) {
const data = await res.json()
if (data.total !== undefined) setTotal(data.total)
const backendEvidence = data.evidence || data
if (Array.isArray(backendEvidence) && backendEvidence.length > 0) {
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => ({
id: (e.id || '') as string,
controlId: (e.control_id || '') as string,
type: ((e.evidence_type || 'DOCUMENT') as string).toUpperCase() as EvidenceType,
name: (e.title || e.name || '') as string,
description: (e.description || '') as string,
fileUrl: (e.artifact_url || null) as string | null,
validFrom: e.valid_from ? new Date(e.valid_from as string) : new Date(),
validUntil: e.valid_until ? new Date(e.valid_until as string) : null,
uploadedBy: (e.uploaded_by || 'System') as string,
uploadedAt: e.created_at ? new Date(e.created_at as string) : new Date(),
}))
dispatch({ type: 'SET_STATE', payload: { evidence: mapped } })
setError(null)
return
}
}
loadFromTemplates()
} catch {
loadFromTemplates()
} finally {
setLoading(false)
}
}
const loadFromTemplates = () => {
if (state.evidence.length > 0) return
if (state.controls.length === 0) return
const relevantEvidence = evidenceTemplates.filter(e =>
state.controls.some(c => c.id === e.controlId || e.linkedControls.includes(c.id))
)
const now = new Date()
relevantEvidence.forEach(template => {
const validFrom = new Date(now)
validFrom.setMonth(validFrom.getMonth() - 1)
const validUntil = template.validityDays > 0
? new Date(validFrom.getTime() + template.validityDays * 24 * 60 * 60 * 1000)
: null
const sdkEvidence: SDKEvidence = {
id: template.id,
controlId: template.controlId,
type: template.type,
name: template.name,
description: template.description,
fileUrl: null,
validFrom,
validUntil,
uploadedBy: template.uploadedBy,
uploadedAt: validFrom,
}
dispatch({ type: 'ADD_EVIDENCE', payload: sdkEvidence })
})
}
fetchEvidence()
}, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
// Convert SDK evidence to display evidence
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
const template = evidenceTemplates.find(t => t.id === ev.id)
return {
id: ev.id,
name: ev.name,
description: ev.description,
displayType: mapEvidenceTypeToDisplay(ev.type),
format: template?.format || 'pdf',
controlId: ev.controlId,
linkedRequirements: template?.linkedRequirements || [],
linkedControls: template?.linkedControls || [ev.controlId],
uploadedBy: ev.uploadedBy,
uploadedAt: ev.uploadedAt,
validFrom: ev.validFrom,
validUntil: ev.validUntil,
status: getEvidenceStatus(ev.validUntil),
fileSize: template?.fileSize || 'Unbekannt',
fileUrl: ev.fileUrl,
}
})
const filteredEvidence = filter === 'all'
? displayEvidence
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
const validCount = displayEvidence.filter(e => e.status === 'valid').length
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
const pendingCount = displayEvidence.filter(e => e.status === 'pending-review').length
const handleDelete = async (evidenceId: string) => {
if (!confirm('Moechten Sie diesen Nachweis wirklich loeschen?')) return
dispatch({ type: 'DELETE_EVIDENCE', payload: evidenceId })
try {
await fetch(`/api/sdk/v1/compliance/evidence/${evidenceId}`, {
method: 'DELETE',
})
} catch {
// Silently fail — SDK state is already updated
}
}
const handleUpload = async (file: File) => {
setUploading(true)
setError(null)
try {
// Use the first control as default, or a generic one
const controlId = state.controls.length > 0 ? state.controls[0].id : 'GENERIC'
const params = new URLSearchParams({
control_id: controlId,
evidence_type: 'document',
title: file.name,
})
const formData = new FormData()
formData.append('file', file)
const res = await fetch(`/api/sdk/v1/compliance/evidence/upload?${params}`, {
method: 'POST',
body: formData,
})
if (!res.ok) {
const errData = await res.json().catch(() => ({ error: 'Upload fehlgeschlagen' }))
throw new Error(errData.error || errData.detail || 'Upload fehlgeschlagen')
}
const data = await res.json()
// Add to SDK state
const newEvidence: SDKEvidence = {
id: data.id || `ev-${Date.now()}`,
controlId: controlId,
type: 'DOCUMENT',
name: file.name,
description: `Hochgeladen am ${new Date().toLocaleDateString('de-DE')}`,
fileUrl: data.artifact_url || null,
validFrom: new Date(),
validUntil: null,
uploadedBy: 'Aktueller Benutzer',
uploadedAt: new Date(),
}
dispatch({ type: 'ADD_EVIDENCE', payload: newEvidence })
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
const handleView = (ev: DisplayEvidence) => {
if (ev.fileUrl) {
window.open(ev.fileUrl, '_blank')
} else {
alert('Keine Datei vorhanden')
}
}
const handleDownload = (ev: DisplayEvidence) => {
if (!ev.fileUrl) return
const a = document.createElement('a')
a.href = ev.fileUrl
a.download = ev.name
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
e.target.value = '' // Reset input
}
}
// Load checks when tab changes
const loadChecks = async () => {
setChecksLoading(true)
try {
const res = await fetch(`${CHECK_API}?limit=50`)
if (res.ok) {
const data = await res.json()
setChecks(data.checks || [])
}
} catch { /* silent */ }
finally { setChecksLoading(false) }
}
const runCheck = async (checkId: string) => {
setRunningCheckId(checkId)
try {
const res = await fetch(`${CHECK_API}/${checkId}/run`, { method: 'POST' })
if (res.ok) {
const result = await res.json()
setCheckResults(prev => ({
...prev,
[checkId]: [result, ...(prev[checkId] || [])].slice(0, 5),
}))
loadChecks() // refresh last_run_at
}
} catch { /* silent */ }
finally { setRunningCheckId(null) }
}
const loadCheckResults = async (checkId: string) => {
try {
const res = await fetch(`${CHECK_API}/${checkId}/results?limit=5`)
if (res.ok) {
const data = await res.json()
setCheckResults(prev => ({ ...prev, [checkId]: data.results || [] }))
}
} catch { /* silent */ }
}
const seedChecks = async () => {
setSeedingChecks(true)
try {
await fetch(`${CHECK_API}/seed`, { method: 'POST' })
loadChecks()
} catch { /* silent */ }
finally { setSeedingChecks(false) }
}
const loadMappings = async () => {
try {
const res = await fetch(`${CHECK_API}/mappings`)
if (res.ok) {
const data = await res.json()
setMappings(data.mappings || [])
}
} catch { /* silent */ }
}
const loadCoverageReport = async () => {
try {
const res = await fetch(`${CHECK_API}/mappings/report`)
if (res.ok) setCoverageReport(await res.json())
} catch { /* silent */ }
}
useEffect(() => {
if (activeTab === 'checks' && checks.length === 0) loadChecks()
if (activeTab === 'mapping') { loadMappings(); loadCoverageReport() }
if (activeTab === 'report') loadCoverageReport()
}, [activeTab]) // eslint-disable-line react-hooks/exhaustive-deps
const stepInfo = STEP_EXPLANATIONS['evidence']
const evidenceTabs: { key: EvidenceTabKey; label: string }[] = [
{ key: 'evidence', label: 'Nachweise' },
{ key: 'checks', label: 'Automatische Checks' },
{ key: 'mapping', label: 'Control-Mapping' },
{ key: 'report', label: 'Report' },
]
return (
<div className="space-y-6">
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg,.json,.csv,.txt"
/>
{/* Step Header */}
<StepHeader
stepId="evidence"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<button
onClick={handleUploadClick}
disabled={uploading}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
{uploading ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Wird hochgeladen...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Nachweis hochladen
</>
)}
</button>
</StepHeader>
{/* Tab Navigation */}
<div className="bg-white rounded-xl shadow-sm border">
<div className="flex border-b">
{evidenceTabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-6 py-3 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'text-purple-600 border-b-2 border-purple-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Error Banner */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">&times;</button>
</div>
)}
{/* ============================================================ */}
{/* TAB: Nachweise (existing content) */}
{/* ============================================================ */}
{activeTab === 'evidence' && <>
{/* Controls Alert */}
{state.controls.length === 0 && !loading && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-amber-600 mt-0.5" 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>
<div>
<h4 className="font-medium text-amber-800">Keine Kontrollen definiert</h4>
<p className="text-sm text-amber-700 mt-1">
Bitte definieren Sie zuerst Kontrollen, um die zugehoerigen Nachweise zu laden.
</p>
</div>
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{displayEvidence.length}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Gueltig</div>
<div className="text-3xl font-bold text-green-600">{validCount}</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6">
<div className="text-sm text-red-600">Abgelaufen</div>
<div className="text-3xl font-bold text-red-600">{expiredCount}</div>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-6">
<div className="text-sm text-yellow-600">Pruefung ausstehend</div>
<div className="text-3xl font-bold text-yellow-600">{pendingCount}</div>
</div>
</div>
{/* Filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'valid', 'expired', 'pending-review', 'document', 'certificate', 'audit-report'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filter === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' :
f === 'valid' ? 'Gueltig' :
f === 'expired' ? 'Abgelaufen' :
f === 'pending-review' ? 'Ausstehend' :
f === 'document' ? 'Dokumente' :
f === 'certificate' ? 'Zertifikate' : 'Audit-Berichte'}
</button>
))}
</div>
{/* Loading State */}
{loading && <LoadingSkeleton />}
{/* Evidence List */}
{!loading && (
<div className="space-y-4">
{filteredEvidence.map(ev => (
<EvidenceCard
key={ev.id}
evidence={ev}
onDelete={() => handleDelete(ev.id)}
onView={() => handleView(ev)}
onDownload={() => handleDownload(ev)}
/>
))}
</div>
)}
{/* Pagination */}
{!loading && total > pageSize && (
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
<span className="text-sm text-gray-500">
Zeige {((page - 1) * pageSize) + 1}{Math.min(page * pageSize, total)} von {total} Nachweisen
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Zurueck
</button>
<span className="text-sm text-gray-700">
Seite {page} von {Math.ceil(total / pageSize)}
</span>
<button
onClick={() => setPage(p => Math.min(Math.ceil(total / pageSize), p + 1))}
disabled={page >= Math.ceil(total / pageSize)}
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
>
Weiter
</button>
</div>
</div>
)}
{!loading && filteredEvidence.length === 0 && state.controls.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" 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>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Nachweise gefunden</h3>
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder laden Sie neue Nachweise hoch.</p>
</div>
)}
</>}
{/* ============================================================ */}
{/* TAB: Automatische Checks */}
{/* ============================================================ */}
{activeTab === 'checks' && (
<>
{/* Seed if empty */}
{!checksLoading && checks.length === 0 && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg flex items-center justify-between">
<div>
<p className="font-medium text-yellow-800">Keine automatischen Checks vorhanden</p>
<p className="text-sm text-yellow-700">Laden Sie ca. 15 Standard-Checks (TLS, Header, Zertifikate, etc.).</p>
</div>
<button onClick={seedChecks} disabled={seedingChecks}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 disabled:opacity-50">
{seedingChecks ? 'Lade...' : 'Standard-Checks laden'}
</button>
</div>
)}
{checksLoading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<div className="space-y-3">
{checks.map(check => {
const typeMeta = CHECK_TYPE_LABELS[check.check_type] || { label: check.check_type, color: 'bg-gray-100 text-gray-700' }
const results = checkResults[check.id] || []
const lastResult = results[0]
const isRunning = runningCheckId === check.id
return (
<div key={check.id} className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900">{check.title}</h4>
<span className={`px-2 py-0.5 text-xs rounded ${typeMeta.color}`}>{typeMeta.label}</span>
{!check.is_active && (
<span className="px-2 py-0.5 text-xs rounded bg-gray-100 text-gray-500">Deaktiviert</span>
)}
</div>
{check.description && <p className="text-sm text-gray-500 mt-1">{check.description}</p>}
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
<span>Code: {check.check_code}</span>
{check.target_url && <span>Ziel: {check.target_url}</span>}
<span>Frequenz: {check.frequency}</span>
{check.last_run_at && <span>Letzter Lauf: {new Date(check.last_run_at).toLocaleDateString('de-DE')}</span>}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
{lastResult && (
<span className={`px-2 py-0.5 text-xs rounded ${RUN_STATUS_LABELS[lastResult.run_status]?.color || ''}`}>
{RUN_STATUS_LABELS[lastResult.run_status]?.label || lastResult.run_status}
</span>
)}
<button
onClick={() => { runCheck(check.id); loadCheckResults(check.id) }}
disabled={isRunning}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{isRunning ? 'Laeuft...' : 'Ausfuehren'}
</button>
<button
onClick={() => loadCheckResults(check.id)}
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50"
>
Historie
</button>
</div>
</div>
{/* Results */}
{results.length > 0 && (
<div className="mt-3 border-t pt-3">
<p className="text-xs font-medium text-gray-500 mb-2">Letzte Ergebnisse</p>
<div className="space-y-1">
{results.slice(0, 3).map(r => (
<div key={r.id} className="flex items-center gap-3 text-xs">
<span className={`px-1.5 py-0.5 rounded ${RUN_STATUS_LABELS[r.run_status]?.color || 'bg-gray-100'}`}>
{RUN_STATUS_LABELS[r.run_status]?.label || r.run_status}
</span>
<span className="text-gray-500">{new Date(r.run_at).toLocaleString('de-DE')}</span>
<span className="text-gray-400">{r.duration_ms}ms</span>
{r.findings_count > 0 && (
<span className="text-orange-600">{r.findings_count} Findings ({r.critical_findings} krit.)</span>
)}
{r.summary && <span className="text-gray-600 truncate">{r.summary}</span>}
</div>
))}
</div>
</div>
)}
</div>
)
})}
</div>
)}
</>
)}
{/* ============================================================ */}
{/* TAB: Control-Mapping */}
{/* ============================================================ */}
{activeTab === 'mapping' && (
<>
{coverageReport && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<p className="text-sm text-gray-500">Gesamt Controls</p>
<p className="text-3xl font-bold text-gray-900">{coverageReport.total_controls}</p>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<p className="text-sm text-green-600">Mit Nachweis</p>
<p className="text-3xl font-bold text-green-600">{coverageReport.controls_with_evidence}</p>
</div>
<div className="bg-white rounded-xl border border-red-200 p-6">
<p className="text-sm text-red-600">Ohne Nachweis</p>
<p className="text-3xl font-bold text-red-600">{coverageReport.controls_without_evidence}</p>
</div>
<div className="bg-white rounded-xl border border-purple-200 p-6">
<p className="text-sm text-purple-600">Abdeckung</p>
<p className="text-3xl font-bold text-purple-600">{coverageReport.coverage_percent.toFixed(0)}%</p>
</div>
</div>
)}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b">
<h3 className="font-semibold text-gray-900">Evidence-Control-Verknuepfungen ({mappings.length})</h3>
</div>
{mappings.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<p>Noch keine Verknuepfungen erstellt.</p>
<p className="text-sm mt-1">Fuehren Sie automatische Checks aus, um Nachweise automatisch mit Controls zu verknuepfen.</p>
</div>
) : (
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Control</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Evidence</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Verifiziert</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{mappings.map(m => (
<tr key={m.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-sm text-purple-600">{m.control_code}</td>
<td className="px-4 py-3 text-sm text-gray-700">{m.evidence_id.slice(0, 8)}...</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-700">{m.mapping_type}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{m.verified_at ? `${new Date(m.verified_at).toLocaleDateString('de-DE')} von ${m.verified_by || '—'}` : 'Ausstehend'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
{/* ============================================================ */}
{/* TAB: Report */}
{/* ============================================================ */}
{activeTab === 'report' && (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Evidence Coverage Report</h3>
{!coverageReport ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : (
<>
{/* Coverage Bar */}
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Gesamt-Abdeckung</span>
<span className={`text-2xl font-bold ${
coverageReport.coverage_percent >= 80 ? 'text-green-600' :
coverageReport.coverage_percent >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>
{coverageReport.coverage_percent.toFixed(1)}%
</span>
</div>
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
coverageReport.coverage_percent >= 80 ? 'bg-green-500' :
coverageReport.coverage_percent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${coverageReport.coverage_percent}%` }}
/>
</div>
</div>
{/* Summary */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="p-4 bg-gray-50 rounded-lg text-center">
<p className="text-3xl font-bold text-gray-900">{coverageReport.total_controls}</p>
<p className="text-sm text-gray-500">Controls gesamt</p>
</div>
<div className="p-4 bg-green-50 rounded-lg text-center">
<p className="text-3xl font-bold text-green-600">{coverageReport.controls_with_evidence}</p>
<p className="text-sm text-green-600">Mit Nachweis belegt</p>
</div>
<div className="p-4 bg-red-50 rounded-lg text-center">
<p className="text-3xl font-bold text-red-600">{coverageReport.controls_without_evidence}</p>
<p className="text-sm text-red-600">Ohne Nachweis</p>
</div>
</div>
{/* Check Summary */}
<div className="border-t pt-6">
<h4 className="font-medium text-gray-900 mb-3">Automatische Checks</h4>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>{checks.length} Check-Definitionen</span>
<span>{checks.filter(c => c.is_active).length} aktiv</span>
<span>{checks.filter(c => c.last_run_at).length} mindestens 1x ausgefuehrt</span>
</div>
</div>
{/* Evidence Summary */}
<div className="border-t pt-6 mt-6">
<h4 className="font-medium text-gray-900 mb-3">Nachweise</h4>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>{displayEvidence.length} Nachweise gesamt</span>
<span className="text-green-600">{validCount} gueltig</span>
<span className="text-red-600">{expiredCount} abgelaufen</span>
<span className="text-yellow-600">{pendingCount} ausstehend</span>
</div>
</div>
</>
)}
</div>
)}
</div>
)
}