refactor(admin): split evidence, process-tasks, iace/hazards pages
Extract components and hooks into _components/ and _hooks/ subdirectories to reduce each page.tsx to under 500 LOC (was 1545/1383/1316). Final line counts: evidence=213, process-tasks=304, hazards=157. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function AuditTrailPanel({ evidenceId, onClose }: { evidenceId: string; onClose: () => void }) {
|
||||
const [entries, setEntries] = useState<{ id: string; action: string; actor: string; timestamp: string; details: Record<string, unknown> | null }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/sdk/v1/compliance/audit-trail?entity_type=evidence&entity_id=${evidenceId}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const mapped = (data.entries || []).map((e: Record<string, unknown>) => ({
|
||||
id: e.id as string,
|
||||
action: e.action as string,
|
||||
actor: (e.performed_by || 'System') as string,
|
||||
timestamp: (e.performed_at || '') as string,
|
||||
details: {
|
||||
...(e.field_changed ? { field: e.field_changed } : {}),
|
||||
...(e.old_value ? { old: e.old_value } : {}),
|
||||
...(e.new_value ? { new: e.new_value } : {}),
|
||||
...(e.change_summary ? { summary: e.change_summary } : {}),
|
||||
} as Record<string, unknown>,
|
||||
}))
|
||||
setEntries(mapped)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [evidenceId])
|
||||
|
||||
const actionLabels: Record<string, { label: string; color: string }> = {
|
||||
created: { label: 'Erstellt', color: 'bg-blue-100 text-blue-700' },
|
||||
uploaded: { label: 'Hochgeladen', color: 'bg-purple-100 text-purple-700' },
|
||||
reviewed: { label: 'Reviewed', color: 'bg-green-100 text-green-700' },
|
||||
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' },
|
||||
updated: { label: 'Aktualisiert', color: 'bg-yellow-100 text-yellow-700' },
|
||||
deleted: { label: 'Geloescht', color: 'bg-gray-100 text-gray-700' },
|
||||
approved: { label: 'Genehmigt', color: 'bg-emerald-100 text-emerald-700' },
|
||||
four_eyes_first: { label: '1. Review (4-Augen)', color: 'bg-blue-100 text-blue-700' },
|
||||
four_eyes_final: { label: 'Finale Freigabe (4-Augen)', color: 'bg-emerald-100 text-emerald-700' },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-2xl mx-4 p-6 max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Audit-Trail</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">
|
||||
<p>Keine Audit-Trail-Eintraege vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200" />
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry, idx) => {
|
||||
const meta = actionLabels[entry.action] || { label: entry.action, color: 'bg-gray-100 text-gray-700' }
|
||||
return (
|
||||
<div key={entry.id || idx} className="relative flex items-start gap-4 pl-10">
|
||||
<div className="absolute left-2.5 top-1.5 w-3 h-3 rounded-full bg-white border-2 border-purple-400" />
|
||||
<div className="flex-1 bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${meta.color}`}>{meta.label}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{entry.timestamp ? new Date(entry.timestamp).toLocaleString('de-DE') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<span className="font-medium">{entry.actor || 'System'}</span>
|
||||
</div>
|
||||
{entry.details && Object.keys(entry.details).length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500 font-mono bg-white rounded p-2 border">
|
||||
{Object.entries(entry.details).map(([k, v]) => (
|
||||
<div key={k}><span className="text-gray-400">{k}:</span> {String(v)}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
admin-compliance/app/sdk/evidence/_components/ChecksTab.tsx
Normal file
115
admin-compliance/app/sdk/evidence/_components/ChecksTab.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import type { EvidenceCheck, CheckResult, CHECK_TYPE_LABELS, RUN_STATUS_LABELS } from './EvidenceTypes'
|
||||
import { CHECK_TYPE_LABELS as checkTypeLabels, RUN_STATUS_LABELS as runStatusLabels } from './EvidenceTypes'
|
||||
|
||||
export function ChecksTab({
|
||||
checks,
|
||||
checksLoading,
|
||||
checkResults,
|
||||
runningCheckId,
|
||||
seedingChecks,
|
||||
onRun,
|
||||
onLoadResults,
|
||||
onSeed,
|
||||
}: {
|
||||
checks: EvidenceCheck[]
|
||||
checksLoading: boolean
|
||||
checkResults: Record<string, CheckResult[]>
|
||||
runningCheckId: string | null
|
||||
seedingChecks: boolean
|
||||
onRun: (id: string) => void
|
||||
onLoadResults: (id: string) => void
|
||||
onSeed: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!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={onSeed} 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 = checkTypeLabels[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 ${runStatusLabels[lastResult.run_status]?.color || ''}`}>
|
||||
{runStatusLabels[lastResult.run_status]?.label || lastResult.run_status}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => { onRun(check.id); onLoadResults(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={() => onLoadResults(check.id)}
|
||||
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50">
|
||||
Historie
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 ${runStatusLabels[r.run_status]?.color || 'bg-gray-100'}`}>
|
||||
{runStatusLabels[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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
ConfidenceLevelBadge,
|
||||
TruthStatusBadge,
|
||||
GenerationModeBadge,
|
||||
ApprovalStatusBadge,
|
||||
} from '../components/anti-fake-badges'
|
||||
import type { DisplayEvidence, DisplayEvidenceType } from './EvidenceTypes'
|
||||
|
||||
const typeIcons: Record<DisplayEvidenceType, React.ReactNode> = {
|
||||
@@ -50,16 +57,14 @@ const typeIconBg: Record<DisplayEvidenceType, string> = {
|
||||
document: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
export function EvidenceCard({
|
||||
evidence,
|
||||
onDelete,
|
||||
onView,
|
||||
onDownload,
|
||||
}: {
|
||||
export function EvidenceCard({ evidence, onDelete, onView, onDownload, onReview, onReject, onShowHistory }: {
|
||||
evidence: DisplayEvidence
|
||||
onDelete: () => void
|
||||
onView: () => void
|
||||
onDownload: () => void
|
||||
onReview: () => void
|
||||
onReject: () => void
|
||||
onShowHistory: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
@@ -73,9 +78,15 @@ export function EvidenceCard({
|
||||
<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 className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
|
||||
{statusLabels[evidence.status]}
|
||||
</span>
|
||||
<ConfidenceLevelBadge level={evidence.confidenceLevel} />
|
||||
<TruthStatusBadge status={evidence.truthStatus} />
|
||||
<GenerationModeBadge mode={evidence.generationMode} />
|
||||
<ApprovalStatusBadge status={evidence.approvalStatus} requiresFourEyes={evidence.requiresFourEyes} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{evidence.description}</p>
|
||||
|
||||
@@ -91,14 +102,10 @@ export function EvidenceCard({
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<span key={ctrl} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">{ctrl}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,26 +114,34 @@ export function EvidenceCard({
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<button onClick={onDelete}
|
||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
Loeschen
|
||||
</button>
|
||||
{(evidence.approvalStatus === 'none' || evidence.approvalStatus === 'pending_first' || evidence.approvalStatus === 'first_approved' || !evidence.approvalStatus) && evidence.approvalStatus !== 'approved' && evidence.approvalStatus !== 'rejected' && (
|
||||
<button onClick={onReview}
|
||||
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors font-medium">
|
||||
Reviewen
|
||||
</button>
|
||||
)}
|
||||
{evidence.requiresFourEyes && evidence.approvalStatus !== 'rejected' && evidence.approvalStatus !== 'approved' && (
|
||||
<button onClick={onReject}
|
||||
className="px-3 py-1 text-sm text-orange-600 hover:bg-orange-50 rounded-lg transition-colors">
|
||||
Ablehnen
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onShowHistory}
|
||||
className="px-3 py-1 text-sm text-gray-500 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Historie
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,12 @@ export interface DisplayEvidence {
|
||||
status: DisplayStatus
|
||||
fileSize: string
|
||||
fileUrl: string | null
|
||||
// Anti-Fake-Evidence Phase 2
|
||||
confidenceLevel: string | null
|
||||
truthStatus: string | null
|
||||
generationMode: string | null
|
||||
approvalStatus: string | null
|
||||
requiresFourEyes: boolean
|
||||
}
|
||||
|
||||
export interface EvidenceTemplate {
|
||||
@@ -37,6 +43,77 @@ export interface EvidenceTemplate {
|
||||
fileSize: string
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export interface EvidenceMapping {
|
||||
id: string
|
||||
evidence_id: string
|
||||
control_code: string
|
||||
mapping_type: string
|
||||
verified_at: string | null
|
||||
verified_by: string | null
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
export interface CoverageReport {
|
||||
total_controls: number
|
||||
controls_with_evidence: number
|
||||
controls_without_evidence: number
|
||||
coverage_percent: number
|
||||
}
|
||||
|
||||
export type EvidenceTabKey = 'evidence' | 'checks' | 'mapping' | 'report'
|
||||
|
||||
export 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' },
|
||||
}
|
||||
|
||||
export 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' },
|
||||
}
|
||||
|
||||
export const confidenceFilterColors: Record<string, string> = {
|
||||
E0: 'bg-red-200 text-red-800',
|
||||
E1: 'bg-yellow-200 text-yellow-800',
|
||||
E2: 'bg-blue-200 text-blue-800',
|
||||
E3: 'bg-green-200 text-green-800',
|
||||
E4: 'bg-emerald-200 text-emerald-800',
|
||||
}
|
||||
|
||||
export const CHECK_API = '/api/sdk/v1/compliance/evidence-checks'
|
||||
|
||||
export function mapEvidenceTypeToDisplay(type: EvidenceType): DisplayEvidenceType {
|
||||
switch (type) {
|
||||
case 'DOCUMENT': return 'document'
|
||||
|
||||
73
admin-compliance/app/sdk/evidence/_components/MappingTab.tsx
Normal file
73
admin-compliance/app/sdk/evidence/_components/MappingTab.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import type { EvidenceMapping, CoverageReport } from './EvidenceTypes'
|
||||
|
||||
export function MappingTab({
|
||||
mappings,
|
||||
coverageReport,
|
||||
}: {
|
||||
mappings: EvidenceMapping[]
|
||||
coverageReport: CoverageReport | null
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { DisplayEvidence } from './EvidenceTypes'
|
||||
|
||||
export function RejectModal({ evidence, onClose, onSuccess }: {
|
||||
evidence: DisplayEvidence
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [reviewedBy, setReviewedBy] = useState('')
|
||||
const [rejectionReason, setRejectionReason] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reviewedBy.trim()) { setError('Bitte E-Mail-Adresse angeben'); return }
|
||||
if (!rejectionReason.trim()) { setError('Bitte Ablehnungsgrund angeben'); return }
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/evidence/${evidence.id}/reject`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reviewed_by: reviewedBy, rejection_reason: rejectionReason }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Ablehnung fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Evidence Ablehnen</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">{evidence.name}</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Reviewer (E-Mail)</label>
|
||||
<input type="email" value={reviewedBy} onChange={e => setReviewedBy(e.target.value)}
|
||||
placeholder="reviewer@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-red-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ablehnungsgrund</label>
|
||||
<textarea value={rejectionReason} onChange={e => setRejectionReason(e.target.value)}
|
||||
placeholder="Bitte beschreiben Sie den Grund fuer die Ablehnung..."
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting}
|
||||
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50">
|
||||
{submitting ? 'Wird abgelehnt...' : 'Ablehnen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
admin-compliance/app/sdk/evidence/_components/ReportTab.tsx
Normal file
88
admin-compliance/app/sdk/evidence/_components/ReportTab.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import type { EvidenceCheck, CoverageReport } from './EvidenceTypes'
|
||||
|
||||
export function ReportTab({
|
||||
coverageReport,
|
||||
checks,
|
||||
displayEvidenceLength,
|
||||
validCount,
|
||||
expiredCount,
|
||||
pendingCount,
|
||||
}: {
|
||||
coverageReport: CoverageReport | null
|
||||
checks: EvidenceCheck[]
|
||||
displayEvidenceLength: number
|
||||
validCount: number
|
||||
expiredCount: number
|
||||
pendingCount: number
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>{displayEvidenceLength} 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>
|
||||
)
|
||||
}
|
||||
125
admin-compliance/app/sdk/evidence/_components/ReviewModal.tsx
Normal file
125
admin-compliance/app/sdk/evidence/_components/ReviewModal.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { DisplayEvidence } from './EvidenceTypes'
|
||||
|
||||
export function ReviewModal({ evidence, onClose, onSuccess }: {
|
||||
evidence: DisplayEvidence
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [confidenceLevel, setConfidenceLevel] = useState(evidence.confidenceLevel || 'E1')
|
||||
const [truthStatus, setTruthStatus] = useState(evidence.truthStatus || 'uploaded')
|
||||
const [reviewedBy, setReviewedBy] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reviewedBy.trim()) { setError('Bitte E-Mail-Adresse angeben'); return }
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/evidence/${evidence.id}/review`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confidence_level: confidenceLevel, truth_status: truthStatus, reviewed_by: reviewedBy }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Review fehlgeschlagen' }))
|
||||
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confidenceLevels = [
|
||||
{ value: 'E0', label: 'E0 — Generiert' },
|
||||
{ value: 'E1', label: 'E1 — Manuell' },
|
||||
{ value: 'E2', label: 'E2 — Intern validiert' },
|
||||
{ value: 'E3', label: 'E3 — System-beobachtet' },
|
||||
{ value: 'E4', label: 'E4 — Extern auditiert' },
|
||||
]
|
||||
|
||||
const truthStatuses = [
|
||||
{ value: 'generated', label: 'Generiert' },
|
||||
{ value: 'uploaded', label: 'Hochgeladen' },
|
||||
{ value: 'observed', label: 'Beobachtet' },
|
||||
{ value: 'validated', label: 'Validiert' },
|
||||
{ value: 'audited', label: 'Auditiert' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Evidence Reviewen</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">{evidence.name}</p>
|
||||
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Aktuelles Confidence-Level:</span>
|
||||
<span className="font-medium">{evidence.confidenceLevel || '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Aktueller Truth-Status:</span>
|
||||
<span className="font-medium">{evidence.truthStatus || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Confidence-Level</label>
|
||||
<select value={confidenceLevel} onChange={e => setConfidenceLevel(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{confidenceLevels.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neuer Truth-Status</label>
|
||||
<select value={truthStatus} onChange={e => setTruthStatus(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{truthStatuses.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Reviewer (E-Mail)</label>
|
||||
<input type="email" value={reviewedBy} onChange={e => setReviewedBy(e.target.value)}
|
||||
placeholder="reviewer@unternehmen.de"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
{evidence.requiresFourEyes && evidence.approvalStatus !== 'approved' && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0" 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 className="text-sm text-yellow-800">
|
||||
<p className="font-medium">4-Augen-Prinzip aktiv</p>
|
||||
<p>Dieser Nachweis erfordert eine zusaetzliche Freigabe durch einen zweiten Reviewer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50">
|
||||
{submitting ? 'Wird gespeichert...' : 'Review abschliessen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user