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:
Sharang Parnerkar
2026-04-16 17:12:15 +02:00
parent e0c1d21879
commit 1fcd8244b1
27 changed files with 2621 additions and 4083 deletions

View File

@@ -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">&times;</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>
)
}

View 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>
)}
</>
)
}

View File

@@ -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>

View File

@@ -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'

View 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>
</>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -4,13 +4,28 @@ import { useState, useEffect, useRef } from 'react'
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
import {
DisplayEvidence,
EvidenceCheck,
CheckResult,
EvidenceMapping,
CoverageReport,
EvidenceTabKey,
mapEvidenceTypeToDisplay,
getEvidenceStatus,
evidenceTemplates,
CHECK_API,
} from '../_components/EvidenceTypes'
type AntiFakeMeta = Record<string, {
confidenceLevel: string | null
truthStatus: string | null
generationMode: string | null
approvalStatus: string | null
requiresFourEyes: boolean
}>
export function useEvidence() {
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)
@@ -19,6 +34,25 @@ export function useEvidence() {
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
const [antiFakeMeta, setAntiFakeMeta] = useState<AntiFakeMeta>({})
// 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)
// Phase 3: Review/Reject/AuditTrail state
const [reviewEvidence, setReviewEvidence] = useState<DisplayEvidence | null>(null)
const [rejectEvidence, setRejectEvidence] = useState<DisplayEvidence | null>(null)
const [auditTrailId, setAuditTrailId] = useState<string | null>(null)
const [confidenceFilter, setConfidenceFilter] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
useEffect(() => {
const fetchEvidence = async () => {
@@ -30,18 +64,30 @@ export function useEvidence() {
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(),
}))
const metaMap: AntiFakeMeta = {}
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => {
const id = (e.id || '') as string
metaMap[id] = {
confidenceLevel: (e.confidence_level || null) as string | null,
truthStatus: (e.truth_status || null) as string | null,
generationMode: (e.generation_mode || null) as string | null,
approvalStatus: (e.approval_status || null) as string | null,
requiresFourEyes: !!e.requires_four_eyes,
}
return {
id,
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(),
}
})
setAntiFakeMeta(metaMap)
dispatch({ type: 'SET_STATE', payload: { evidence: mapped } })
setError(null)
return
@@ -58,20 +104,16 @@ export function useEvidence() {
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,
@@ -89,10 +131,11 @@ export function useEvidence() {
}
fetchEvidence()
}, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
}, [page, pageSize, refreshKey]) // eslint-disable-line react-hooks/exhaustive-deps
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
const template = evidenceTemplates.find(t => t.id === ev.id)
const meta = antiFakeMeta[ev.id]
return {
id: ev.id,
name: ev.name,
@@ -109,12 +152,18 @@ export function useEvidence() {
status: getEvidenceStatus(ev.validUntil),
fileSize: template?.fileSize || 'Unbekannt',
fileUrl: ev.fileUrl,
confidenceLevel: meta?.confidenceLevel || null,
truthStatus: meta?.truthStatus || null,
generationMode: meta?.generationMode || null,
approvalStatus: meta?.approvalStatus || null,
requiresFourEyes: meta?.requiresFourEyes || false,
}
})
const filteredEvidence = filter === 'all'
const filteredEvidence = (filter === 'all'
? displayEvidence
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
).filter(e => !confidenceFilter || e.confidenceLevel === confidenceFilter)
const validCount = displayEvidence.filter(e => e.status === 'valid').length
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
@@ -125,9 +174,7 @@ export function useEvidence() {
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
}
} catch { /* Silently fail */ }
}
const handleUpload = async (file: File) => {
@@ -135,17 +182,10 @@ export function useEvidence() {
setError(null)
try {
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 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,
})
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')
@@ -153,7 +193,7 @@ export function useEvidence() {
const data = await res.json()
const newEvidence: SDKEvidence = {
id: data.id || `ev-${Date.now()}`,
controlId: controlId,
controlId,
type: 'DOCUMENT',
name: file.name,
description: `Hochgeladen am ${new Date().toLocaleDateString('de-DE')}`,
@@ -172,11 +212,8 @@ export function useEvidence() {
}
const handleView = (ev: DisplayEvidence) => {
if (ev.fileUrl) {
window.open(ev.fileUrl, '_blank')
} else {
alert('Keine Datei vorhanden')
}
if (ev.fileUrl) window.open(ev.fileUrl, '_blank')
else alert('Keine Datei vorhanden')
}
const handleDownload = (ev: DisplayEvidence) => {
@@ -189,20 +226,73 @@ export function useEvidence() {
document.body.removeChild(a)
}
const handleUploadClick = () => {
fileInputRef.current?.click()
}
const handleUploadClick = () => fileInputRef.current?.click()
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
e.target.value = ''
}
if (file) { handleUpload(file); e.target.value = '' }
}
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()
}
} 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
return {
state,
activeTab,
setActiveTab,
filter,
setFilter,
loading,
@@ -219,10 +309,29 @@ export function useEvidence() {
validCount,
expiredCount,
pendingCount,
confidenceFilter,
setConfidenceFilter,
reviewEvidence,
setReviewEvidence,
rejectEvidence,
setRejectEvidence,
auditTrailId,
setAuditTrailId,
setRefreshKey,
checks,
checksLoading,
checkResults,
runningCheckId,
seedingChecks,
mappings,
coverageReport,
handleDelete,
handleView,
handleDownload,
handleUploadClick,
handleFileChange,
runCheck,
loadCheckResults,
seedChecks,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { MatchOutput, CATEGORY_LABELS } from './types'
export function AutoSuggestPanel({ matchResult, applying, onApply, onClose }: {
projectId: string
matchResult: MatchOutput
applying: boolean
onApply: (acceptedHazardCats: string[], acceptedMeasureIds: string[], acceptedEvidenceIds: string[], patternIds: string[]) => void
onClose: () => void
}) {
const [selectedHazards, setSelectedHazards] = useState<Set<string>>(
new Set(matchResult.suggested_hazards.map(h => h.category))
)
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(
new Set(matchResult.suggested_measures.map(m => m.measure_id))
)
const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(
new Set(matchResult.suggested_evidence.map(e => e.evidence_id))
)
function toggle<T>(set: Set<T>, setSet: (s: Set<T>) => void, key: T) {
const next = new Set(set)
if (next.has(key)) next.delete(key)
else next.add(key)
setSet(next)
}
const totalSelected = selectedHazards.size + selectedMeasures.size + selectedEvidence.size
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border-2 border-purple-300 p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Pattern-Matching Ergebnisse</h3>
<p className="text-sm text-gray-500">
{matchResult.matched_patterns.length} Patterns erkannt, {matchResult.resolved_tags.length} Tags aufgeloest
</p>
</div>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div>
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Erkannte Patterns ({matchResult.matched_patterns.length})
</h4>
<div className="flex flex-wrap gap-2">
{matchResult.matched_patterns.sort((a, b) => b.priority - a.priority).map(p => (
<span key={p.pattern_id} className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-xs bg-purple-50 text-purple-700 border border-purple-200">
<span className="font-mono">{p.pattern_id}</span>
<span>{p.pattern_name}</span>
<span className="text-purple-400">P:{p.priority}</span>
</span>
))}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="border border-orange-200 rounded-lg p-3 bg-orange-50/50">
<h4 className="text-sm font-semibold text-orange-800 mb-2">Gefaehrdungen ({matchResult.suggested_hazards.length})</h4>
<div className="space-y-2 max-h-48 overflow-auto">
{matchResult.suggested_hazards.map(h => (
<label key={h.category} className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" checked={selectedHazards.has(h.category)}
onChange={() => toggle(selectedHazards, setSelectedHazards, h.category)}
className="mt-0.5 accent-purple-600" />
<div>
<div className="text-xs font-medium text-gray-900">{CATEGORY_LABELS[h.category] || h.category}</div>
<div className="text-xs text-gray-500">Konfidenz: {Math.round(h.confidence * 100)}%</div>
</div>
</label>
))}
</div>
</div>
<div className="border border-green-200 rounded-lg p-3 bg-green-50/50">
<h4 className="text-sm font-semibold text-green-800 mb-2">Massnahmen ({matchResult.suggested_measures.length})</h4>
<div className="space-y-2 max-h-48 overflow-auto">
{matchResult.suggested_measures.map(m => (
<label key={m.measure_id} className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" checked={selectedMeasures.has(m.measure_id)}
onChange={() => toggle(selectedMeasures, setSelectedMeasures, m.measure_id)}
className="mt-0.5 accent-purple-600" />
<div>
<div className="text-xs font-medium text-gray-900 font-mono">{m.measure_id}</div>
<div className="text-xs text-gray-500">von {m.source_patterns.length} Pattern(s)</div>
</div>
</label>
))}
</div>
</div>
<div className="border border-blue-200 rounded-lg p-3 bg-blue-50/50">
<h4 className="text-sm font-semibold text-blue-800 mb-2">Nachweise ({matchResult.suggested_evidence.length})</h4>
<div className="space-y-2 max-h-48 overflow-auto">
{matchResult.suggested_evidence.map(e => (
<label key={e.evidence_id} className="flex items-start gap-2 cursor-pointer">
<input type="checkbox" checked={selectedEvidence.has(e.evidence_id)}
onChange={() => toggle(selectedEvidence, setSelectedEvidence, e.evidence_id)}
className="mt-0.5 accent-purple-600" />
<div>
<div className="text-xs font-medium text-gray-900 font-mono">{e.evidence_id}</div>
<div className="text-xs text-gray-500">von {e.source_patterns.length} Pattern(s)</div>
</div>
</label>
))}
</div>
</div>
</div>
{matchResult.resolved_tags.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 mb-1">Aufgeloeste Tags</h4>
<div className="flex flex-wrap gap-1">
{matchResult.resolved_tags.map(tag => (
<span key={tag} className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">{tag}</span>
))}
</div>
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-gray-200">
<span className="text-sm text-gray-500">{totalSelected} Elemente ausgewaehlt</span>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={() => onApply(
Array.from(selectedHazards),
Array.from(selectedMeasures),
Array.from(selectedEvidence),
matchResult.matched_patterns.map(p => p.pattern_id),
)}
disabled={totalSelected === 0 || applying}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${totalSelected > 0 && !applying ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
{applying ? (
<span className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Wird uebernommen...
</span>
) : `${totalSelected} uebernehmen`}
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,27 +1,37 @@
'use client'
import { useState } from 'react'
import { HazardFormData, HAZARD_CATEGORIES, CATEGORY_LABELS, getRiskLevel, getRiskColor } from './types'
import {
HazardFormData, LifecyclePhase, RoleInfo,
HAZARD_CATEGORIES, CATEGORY_LABELS, getRiskColor, getRiskLevelISO, getRiskLevelLegacy,
} from './types'
import { RiskBadge } from './RiskBadge'
interface HazardFormProps {
export function HazardForm({
onSubmit,
onCancel,
lifecyclePhases,
roles,
}: {
onSubmit: (data: HazardFormData) => void
onCancel: () => void
}
export function HazardForm({ onSubmit, onCancel }: HazardFormProps) {
lifecyclePhases: LifecyclePhase[]
roles: RoleInfo[]
}) {
const [formData, setFormData] = useState<HazardFormData>({
name: '',
description: '',
category: 'mechanical',
component_id: '',
severity: 3,
exposure: 3,
probability: 3,
name: '', description: '', category: 'mechanical_hazard', component_id: '',
severity: 3, exposure: 3, probability: 3, avoidance: 3,
lifecycle_phase: '', trigger_event: '', affected_person: '',
possible_harm: '', hazardous_zone: '', machine_module: '',
})
const [showExtended, setShowExtended] = useState(false)
const rInherent = formData.severity * formData.exposure * formData.probability
const riskLevel = getRiskLevel(rInherent)
const isISOMode = formData.avoidance > 0
const rInherent = isISOMode
? formData.severity * formData.exposure * formData.probability * formData.avoidance
: formData.severity * formData.exposure * formData.probability
const riskLevel = isISOMode ? getRiskLevelISO(rInherent) : getRiskLevelLegacy(rInherent)
const formulaLabel = isISOMode ? 'R = S × F × P × A' : 'R = S × E × P'
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
@@ -30,77 +40,93 @@ export function HazardForm({ onSubmit, onCancel }: HazardFormProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bezeichnung *</label>
<input
type="text"
value={formData.name}
<input type="text" value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Quetschung durch Roboterarm"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: 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 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
{HAZARD_CATEGORIES.map((cat) => (
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>
))}
<select value={formData.category} onChange={(e) => setFormData({ ...formData, category: 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 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
{HAZARD_CATEGORIES.map((cat) => <option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
placeholder="Detaillierte Beschreibung der Gefaehrdung..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2} placeholder="Detaillierte Beschreibung der Gefaehrdung..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
</div>
<div className="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Risikobewertung (S x E x P)</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Schwere (S): <span className="font-bold">{formData.severity}</span>
</label>
<input type="range" min={1} max={5} value={formData.severity}
onChange={(e) => setFormData({ ...formData, severity: Number(e.target.value) })}
className="w-full accent-purple-600"
/>
<div className="flex justify-between text-xs text-gray-400"><span>Gering</span><span>Toedlich</span></div>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Exposition (E): <span className="font-bold">{formData.exposure}</span>
</label>
<input type="range" min={1} max={5} value={formData.exposure}
onChange={(e) => setFormData({ ...formData, exposure: Number(e.target.value) })}
className="w-full accent-purple-600"
/>
<div className="flex justify-between text-xs text-gray-400"><span>Selten</span><span>Staendig</span></div>
</div>
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
Wahrscheinlichkeit (P): <span className="font-bold">{formData.probability}</span>
</label>
<input type="range" min={1} max={5} value={formData.probability}
onChange={(e) => setFormData({ ...formData, probability: Number(e.target.value) })}
className="w-full accent-purple-600"
/>
<div className="flex justify-between text-xs text-gray-400"><span>Unwahrscheinlich</span><span>Sehr wahrscheinlich</span></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Lebensphase</label>
<select value={formData.lifecycle_phase} onChange={(e) => setFormData({ ...formData, lifecycle_phase: 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 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="">-- Keine Auswahl --</option>
{lifecyclePhases.map((p) => <option key={p.id} value={p.id}>{p.label_de}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Betroffene Personen</label>
<select value={formData.affected_person} onChange={(e) => setFormData({ ...formData, affected_person: 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 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="">-- Bitte waehlen --</option>
{roles.map((r) => <option key={r.id} value={r.id}>{r.label_de}</option>)}
</select>
</div>
</div>
<button type="button" onClick={() => setShowExtended(!showExtended)}
className="text-sm text-purple-600 hover:text-purple-700 font-medium">
{showExtended ? 'Weniger Felder anzeigen' : 'Weitere Felder anzeigen (Ausloeser, Gefahrenzone, Modul...)'}
</button>
{showExtended && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-gray-50 dark:bg-gray-750 rounded-lg">
{[
{ label: 'Ausloeseereignis', field: 'trigger_event' as const, placeholder: 'z.B. Schutztuer offen bei Betrieb' },
{ label: 'Moeglicher Schaden', field: 'possible_harm' as const, placeholder: 'z.B. Schwere Quetschverletzung' },
{ label: 'Gefahrenzone', field: 'hazardous_zone' as const, placeholder: 'z.B. Roboter-Arbeitsbereich' },
{ label: 'Maschinenmodul', field: 'machine_module' as const, placeholder: 'z.B. Antriebseinheit' },
].map(({ label, field, placeholder }) => (
<div key={field}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>
<input type="text" value={formData[field] as string}
onChange={(e) => setFormData({ ...formData, [field]: e.target.value })}
placeholder={placeholder}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
</div>
))}
</div>
)}
<div className="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Risikobewertung ({formulaLabel})</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ label: 'Schwere (S)', field: 'severity' as const, low: 'Gering', high: 'Toedlich' },
{ label: 'Haeufigkeit (F)', field: 'exposure' as const, low: 'Selten', high: 'Staendig' },
{ label: 'Wahrscheinlichkeit (P)', field: 'probability' as const, low: 'Unwahrscheinlich', high: 'Sehr wahrscheinlich' },
{ label: 'Vermeidbarkeit (A)', field: 'avoidance' as const, low: 'Leicht', high: 'Unmoeglich' },
].map(({ label, field, low, high }) => (
<div key={field}>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
{label}: <span className="font-bold">{formData[field]}</span>
</label>
<input type="range" min={1} max={5} value={formData[field]}
onChange={(e) => setFormData({ ...formData, [field]: Number(e.target.value) })}
className="w-full accent-purple-600" />
<div className="flex justify-between text-xs text-gray-400"><span>{low}</span><span>{high}</span></div>
</div>
))}
</div>
<div className={`mt-4 p-3 rounded-lg border ${getRiskColor(riskLevel)}`}>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">R_inherent = S x E x P</span>
<span className="text-sm font-medium">{formulaLabel}</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold">{rInherent}</span>
<RiskBadge level={riskLevel} />
@@ -111,13 +137,8 @@ export function HazardForm({ onSubmit, onCancel }: HazardFormProps) {
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
<button onClick={() => onSubmit(formData)} disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
Hinzufuegen
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">

View File

@@ -1,14 +1,13 @@
'use client'
import { Hazard, CATEGORY_LABELS, STATUS_LABELS } from './types'
import { RiskBadge } from './RiskBadge'
import { Hazard, LifecyclePhase, CATEGORY_LABELS, STATUS_LABELS } from './types'
import { RiskBadge, ReviewStatusBadge } from './RiskBadge'
interface HazardTableProps {
export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
hazards: Hazard[]
lifecyclePhases: LifecyclePhase[]
onDelete: (id: string) => void
}
export function HazardTable({ hazards, onDelete }: HazardTableProps) {
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
@@ -17,42 +16,52 @@ export function HazardTable({ hazards, onDelete }: HazardTableProps) {
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bezeichnung</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Kategorie</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Komponente</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">S</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">E</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">F</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">P</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">A</th>
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">R</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Risiko</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Review</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{hazards
.sort((a, b) => b.r_inherent - a.r_inherent)
.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
.map((hazard) => (
<tr key={hazard.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">{hazard.name}</div>
{hazard.name.startsWith('Auto:') && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">Auto</span>
)}
</div>
{hazard.description && (
<div className="text-xs text-gray-500 truncate max-w-[250px]">{hazard.description}</div>
)}
{hazard.lifecycle_phase && (
<div className="text-xs text-purple-500 mt-0.5">
{lifecyclePhases.find(p => p.id === hazard.lifecycle_phase)?.label_de || hazard.lifecycle_phase}
</div>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
<td className="px-4 py-3 text-sm text-gray-600">{hazard.component_name || '--'}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.exposure}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.probability}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.avoidance || '-'}</td>
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-bold">{hazard.r_inherent}</td>
<td className="px-4 py-3"><RiskBadge level={hazard.risk_level} /></td>
<td className="px-4 py-3"><ReviewStatusBadge status={hazard.review_status || 'draft'} /></td>
<td className="px-4 py-3">
<span className="text-xs text-gray-500">{STATUS_LABELS[hazard.status] || hazard.status}</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onDelete(hazard.id)}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
>
<button onClick={() => onDelete(hazard.id)}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>

View File

@@ -3,15 +3,14 @@
import { useState } from 'react'
import { LibraryHazard, HAZARD_CATEGORIES, CATEGORY_LABELS } from './types'
interface LibraryModalProps {
export function LibraryModal({ library, onAdd, onClose }: {
library: LibraryHazard[]
onAdd: (item: LibraryHazard) => void
onClose: () => void
}
export function LibraryModal({ library, onAdd, onClose }: LibraryModalProps) {
}) {
const [search, setSearch] = useState('')
const [filterCat, setFilterCat] = useState('')
const [expandedId, setExpandedId] = useState<string | null>(null)
const filtered = library.filter((h) => {
const matchSearch = !search || h.name.toLowerCase().includes(search.toLowerCase()) || h.description.toLowerCase().includes(search.toLowerCase())
@@ -21,10 +20,10 @@ export function LibraryModal({ library, onAdd, onClose }: LibraryModalProps) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Gefaehrdungsbibliothek</h3>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Gefaehrdungsbibliothek ({filtered.length} Eintraege)</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -32,43 +31,61 @@ export function LibraryModal({ library, onAdd, onClose }: LibraryModalProps) {
</button>
</div>
<div className="flex gap-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)}
placeholder="Suchen..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<select
value={filterCat}
onChange={(e) => setFilterCat(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
<select value={filterCat} onChange={(e) => setFilterCat(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="">Alle Kategorien</option>
{HAZARD_CATEGORIES.map((cat) => (
<option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>
))}
{HAZARD_CATEGORIES.map((cat) => <option key={cat} value={cat}>{CATEGORY_LABELS[cat] || cat}</option>)}
</select>
</div>
</div>
<div className="flex-1 overflow-auto p-4 space-y-2">
{filtered.length > 0 ? (
filtered.map((item) => (
<div key={item.id} className="flex items-center justify-between p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750">
<div className="flex-1 min-w-0 mr-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.name}</div>
<div className="text-xs text-gray-500 truncate">{item.description}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">{CATEGORY_LABELS[item.category] || item.category}</span>
<span className="text-xs text-gray-400">S:{item.default_severity} E:{item.default_exposure} P:{item.default_probability}</span>
<div key={item.id} className="rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750">
<div className="flex items-center justify-between p-3">
<div className="flex-1 min-w-0 mr-3 cursor-pointer"
onClick={() => setExpandedId(expandedId === item.id ? null : item.id)}>
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.name}</div>
<div className="text-xs text-gray-500 truncate">{item.description}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">{CATEGORY_LABELS[item.category] || item.category}</span>
<span className="text-xs text-gray-400">
S:{item.default_severity} F:{item.default_exposure || 3} P:{item.default_probability} A:{item.default_avoidance || 3}
</span>
</div>
</div>
<button onClick={() => onAdd(item)}
className="flex-shrink-0 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
Hinzufuegen
</button>
</div>
<button
onClick={() => onAdd(item)}
className="flex-shrink-0 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Hinzufuegen
</button>
{expandedId === item.id && (
<div className="px-3 pb-3 space-y-2 text-xs">
{item.typical_causes && item.typical_causes.length > 0 && (
<div><span className="font-medium text-gray-600">Typische Ursachen: </span>
<span className="text-gray-500">{item.typical_causes.join(', ')}</span></div>
)}
{item.typical_harm && (
<div><span className="font-medium text-gray-600">Typischer Schaden: </span>
<span className="text-gray-500">{item.typical_harm}</span></div>
)}
{item.recommended_measures_design && item.recommended_measures_design.length > 0 && (
<div><span className="font-medium text-blue-600">Konstruktiv: </span>
<span className="text-gray-500">{item.recommended_measures_design.join(', ')}</span></div>
)}
{item.recommended_measures_technical && item.recommended_measures_technical.length > 0 && (
<div><span className="font-medium text-green-600">Technisch: </span>
<span className="text-gray-500">{item.recommended_measures_technical.join(', ')}</span></div>
)}
{item.recommended_measures_information && item.recommended_measures_information.length > 0 && (
<div><span className="font-medium text-yellow-600">Information: </span>
<span className="text-gray-500">{item.recommended_measures_information.join(', ')}</span></div>
)}
</div>
)}
</div>
))
) : (

View File

@@ -1,6 +1,6 @@
'use client'
import { getRiskColor, getRiskLevelLabel } from './types'
import { getRiskColor, getRiskLevelLabel, REVIEW_STATUS_LABELS } from './types'
export function RiskBadge({ level }: { level: string }) {
return (
@@ -9,3 +9,18 @@ export function RiskBadge({ level }: { level: string }) {
</span>
)
}
export function ReviewStatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600 border-gray-200',
in_review: 'bg-blue-100 text-blue-600 border-blue-200',
reviewed: 'bg-indigo-100 text-indigo-600 border-indigo-200',
approved: 'bg-green-100 text-green-600 border-green-200',
rejected: 'bg-red-100 text-red-600 border-red-200',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${colors[status] || colors.draft}`}>
{REVIEW_STATUS_LABELS[status] || status}
</span>
)
}

View File

@@ -5,13 +5,23 @@ export interface Hazard {
component_id: string | null
component_name: string | null
category: string
sub_category: string
status: string
severity: number
exposure: number
probability: number
avoidance: number
r_inherent: number
risk_level: string
machine_module: string
lifecycle_phase: string
trigger_event: string
affected_person: string
possible_harm: string
hazardous_zone: string
review_status: string
created_at: string
source?: string
}
export interface LibraryHazard {
@@ -19,9 +29,31 @@ export interface LibraryHazard {
name: string
description: string
category: string
sub_category: string
default_severity: number
default_exposure: number
default_probability: number
default_avoidance: number
typical_causes: string[]
typical_harm: string
relevant_lifecycle_phases: string[]
recommended_measures_design: string[]
recommended_measures_technical: string[]
recommended_measures_information: string[]
}
export interface LifecyclePhase {
id: string
label_de: string
label_en: string
sort_order: number
}
export interface RoleInfo {
id: string
label_de: string
label_en: string
sort_order: number
}
export interface HazardFormData {
@@ -32,40 +64,84 @@ export interface HazardFormData {
severity: number
exposure: number
probability: number
avoidance: number
lifecycle_phase: string
trigger_event: string
affected_person: string
possible_harm: string
hazardous_zone: string
machine_module: string
}
// Pattern matching types (Phase 5)
export interface PatternMatch {
pattern_id: string
pattern_name: string
priority: number
matched_tags: string[]
}
export interface HazardSuggestion {
category: string
source_patterns: string[]
confidence: number
}
export interface MeasureSuggestion {
measure_id: string
source_patterns: string[]
}
export interface EvidenceSuggestion {
evidence_id: string
source_patterns: string[]
}
export interface MatchOutput {
matched_patterns: PatternMatch[]
suggested_hazards: HazardSuggestion[]
suggested_measures: MeasureSuggestion[]
suggested_evidence: EvidenceSuggestion[]
resolved_tags: string[]
}
export const HAZARD_CATEGORIES = [
'mechanical', 'electrical', 'thermal', 'noise', 'vibration',
'radiation', 'material', 'ergonomic', 'software', 'ai_specific',
'cybersecurity', 'functional_safety', 'environmental',
'mechanical', 'electrical', 'thermal',
'pneumatic_hydraulic', 'noise_vibration', 'ergonomic',
'material_environmental', 'software_control', 'cyber_network', 'ai_specific',
]
export const CATEGORY_LABELS: Record<string, string> = {
mechanical: 'Mechanisch',
electrical: 'Elektrisch',
thermal: 'Thermisch',
noise: 'Laerm',
vibration: 'Vibration',
radiation: 'Strahlung',
material: 'Stoffe/Materialien',
ergonomic: 'Ergonomie',
software: 'Software',
ai_specific: 'KI-spezifisch',
cybersecurity: 'Cybersecurity',
functional_safety: 'Funktionale Sicherheit',
environmental: 'Umgebung',
mechanical: 'A. Mechanisch', electrical: 'B. Elektrisch', thermal: 'C. Thermisch',
pneumatic_hydraulic: 'D. Pneumatik/Hydraulik', noise_vibration: 'E. Laerm/Vibration',
ergonomic: 'F. Ergonomie', material_environmental: 'G. Stoffe/Umwelt',
software_control: 'H. Software/Steuerung', cyber_network: 'I. Cyber/Netzwerk',
ai_specific: 'J. KI-spezifisch',
// Legacy names (backward compat)
mechanical_hazard: 'A. Mechanisch', electrical_hazard: 'B. Elektrisch',
thermal_hazard: 'C. Thermisch', software_fault: 'H. Software/Steuerung',
safety_function_failure: 'H. Sicherheitsfunktionen', false_classification: 'J. KI-spezifisch',
unauthorized_access: 'I. Cyber/Netzwerk', configuration_error: 'H. Konfiguration',
hmi_error: 'H. HMI-Fehler', integration_error: 'H. Integration',
communication_failure: 'I. Kommunikation', sensor_spoofing: 'I. Sensormanipulation',
model_drift: 'J. Modelldrift', data_poisoning: 'J. Daten-Poisoning',
emc_hazard: 'B. EMV', maintenance_hazard: 'F. Wartung', update_failure: 'H. Update-Fehler',
}
export const STATUS_LABELS: Record<string, string> = {
identified: 'Identifiziert',
assessed: 'Bewertet',
mitigated: 'Gemindert',
accepted: 'Akzeptiert',
closed: 'Geschlossen',
identified: 'Identifiziert', assessed: 'Bewertet',
mitigated: 'Gemindert', accepted: 'Akzeptiert', closed: 'Geschlossen',
}
export const REVIEW_STATUS_LABELS: Record<string, string> = {
draft: 'Entwurf', in_review: 'In Pruefung',
reviewed: 'Geprueft', approved: 'Freigegeben', rejected: 'Abgelehnt',
}
export function getRiskColor(level: string): string {
switch (level) {
case 'not_acceptable': return 'bg-red-200 text-red-900 border-red-300'
case 'very_high': return 'bg-red-100 text-red-700 border-red-200'
case 'critical': return 'bg-red-100 text-red-700 border-red-200'
case 'high': return 'bg-orange-100 text-orange-700 border-orange-200'
case 'medium': return 'bg-yellow-100 text-yellow-700 border-yellow-200'
@@ -74,7 +150,15 @@ export function getRiskColor(level: string): string {
}
}
export function getRiskLevel(r: number): string {
export function getRiskLevelISO(r: number): string {
if (r > 300) return 'not_acceptable'
if (r >= 151) return 'very_high'
if (r >= 61) return 'high'
if (r >= 21) return 'medium'
return 'low'
}
export function getRiskLevelLegacy(r: number): string {
if (r >= 100) return 'critical'
if (r >= 50) return 'high'
if (r >= 20) return 'medium'
@@ -83,6 +167,8 @@ export function getRiskLevel(r: number): string {
export function getRiskLevelLabel(level: string): string {
switch (level) {
case 'not_acceptable': return 'Nicht akzeptabel'
case 'very_high': return 'Sehr hoch'
case 'critical': return 'Kritisch'
case 'high': return 'Hoch'
case 'medium': return 'Mittel'

View File

@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect } from 'react'
import {
Hazard, LibraryHazard, LifecyclePhase, RoleInfo, HazardFormData, MatchOutput,
CATEGORY_LABELS,
} from '../_components/types'
export function useHazards(projectId: string) {
const [hazards, setHazards] = useState<Hazard[]>([])
const [library, setLibrary] = useState<LibraryHazard[]>([])
const [lifecyclePhases, setLifecyclePhases] = useState<LifecyclePhase[]>([])
const [roles, setRoles] = useState<RoleInfo[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [showLibrary, setShowLibrary] = useState(false)
const [suggestingAI, setSuggestingAI] = useState(false)
const [matchingPatterns, setMatchingPatterns] = useState(false)
const [matchResult, setMatchResult] = useState<MatchOutput | null>(null)
const [applyingPatterns, setApplyingPatterns] = useState(false)
useEffect(() => {
fetchHazards()
fetchLifecyclePhases()
fetchRoles()
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
async function fetchHazards() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`)
if (res.ok) {
const json = await res.json()
setHazards(json.hazards || json || [])
}
} catch (err) {
console.error('Failed to fetch hazards:', err)
} finally {
setLoading(false)
}
}
async function fetchLifecyclePhases() {
try {
const res = await fetch('/api/sdk/v1/iace/lifecycle-phases')
if (res.ok) { const json = await res.json(); setLifecyclePhases(json.lifecycle_phases || []) }
} catch (err) { console.error('Failed to fetch lifecycle phases:', err) }
}
async function fetchRoles() {
try {
const res = await fetch('/api/sdk/v1/iace/roles')
if (res.ok) { const json = await res.json(); setRoles(json.roles || []) }
} catch (err) { console.error('Failed to fetch roles:', err) }
}
async function fetchLibrary() {
try {
const res = await fetch('/api/sdk/v1/iace/hazard-library')
if (res.ok) { const json = await res.json(); setLibrary(json.hazard_library || json.hazards || json || []) }
} catch (err) { console.error('Failed to fetch hazard library:', err) }
setShowLibrary(true)
}
async function handleAddFromLibrary(item: LibraryHazard) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: item.name, description: item.description, category: item.category,
sub_category: item.sub_category || '', severity: item.default_severity,
exposure: item.default_exposure || 3, probability: item.default_probability,
avoidance: item.default_avoidance || 3,
}),
})
if (res.ok) await fetchHazards()
} catch (err) { console.error('Failed to add from library:', err) }
}
async function handleSubmit(data: HazardFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) { setShowForm(false); await fetchHazards() }
} catch (err) { console.error('Failed to add hazard:', err) }
}
async function handleAISuggestions() {
setSuggestingAI(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) await fetchHazards()
} catch (err) { console.error('Failed to get AI suggestions:', err) }
finally { setSuggestingAI(false) }
}
async function handlePatternMatching() {
setMatchingPatterns(true)
setMatchResult(null)
try {
const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
let componentLibraryIds: string[] = []
let energySourceIds: string[] = []
if (compRes.ok) {
const compJson = await compRes.json()
const comps = compJson.components || compJson || []
componentLibraryIds = comps.map((c: { library_component_id?: string }) => c.library_component_id).filter(Boolean) as string[]
const allEnergyIds = comps.flatMap((c: { energy_source_ids?: string[] }) => c.energy_source_ids || [])
energySourceIds = [...new Set(allEnergyIds)] as string[]
}
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/match-patterns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ component_library_ids: componentLibraryIds, energy_source_ids: energySourceIds, lifecycle_phases: [], custom_tags: [] }),
})
if (res.ok) setMatchResult(await res.json())
} catch (err) { console.error('Failed to match patterns:', err) }
finally { setMatchingPatterns(false) }
}
async function handleApplyPatterns(
acceptedHazardCats: string[], acceptedMeasureIds: string[],
acceptedEvidenceIds: string[], patternIds: string[],
) {
setApplyingPatterns(true)
try {
const acceptedHazards = acceptedHazardCats.map(cat => ({
name: `Auto: ${CATEGORY_LABELS[cat] || cat}`,
description: `Automatisch erkannte Gefaehrdung aus Pattern-Matching (Kategorie: ${cat})`,
category: cat, severity: 3, exposure: 3, probability: 3, avoidance: 3,
}))
const acceptedMeasures = acceptedMeasureIds.map(id => ({
name: `Auto: Massnahme ${id}`,
description: `Automatisch vorgeschlagene Massnahme aus Pattern-Matching`,
reduction_type: 'design',
}))
const acceptedEvidence = acceptedEvidenceIds.map(id => ({
title: `Auto: Nachweis ${id}`,
description: `Automatisch vorgeschlagener Nachweis aus Pattern-Matching`,
method: 'test_report',
}))
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/apply-patterns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accepted_hazards: acceptedHazards, accepted_measures: acceptedMeasures, accepted_evidence: acceptedEvidence, source_pattern_ids: patternIds }),
})
if (res.ok) { setMatchResult(null); await fetchHazards() }
} catch (err) { console.error('Failed to apply patterns:', err) }
finally { setApplyingPatterns(false) }
}
async function handleDelete(id: string) {
if (!confirm('Gefaehrdung wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${id}`, { method: 'DELETE' })
if (res.ok) await fetchHazards()
} catch (err) { console.error('Failed to delete hazard:', err) }
}
return {
hazards, library, lifecyclePhases, roles, loading,
showForm, setShowForm, showLibrary, setShowLibrary,
suggestingAI, matchingPatterns, matchResult, setMatchResult, applyingPatterns,
fetchLibrary, handleAddFromLibrary, handleSubmit,
handleAISuggestions, handlePatternMatching, handleApplyPatterns, handleDelete,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
'use client'
import { useState } from 'react'
import { ProcessTask, daysUntil } from './types'
export function CalendarView({ tasks }: { tasks: ProcessTask[] }) {
const [currentMonth, setCurrentMonth] = useState(() => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 1)
})
const year = currentMonth.getFullYear()
const month = currentMonth.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOfWeek = new Date(year, month, 1).getDay()
const startOffset = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1
const monthLabel = currentMonth.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
const tasksByDate: Record<string, ProcessTask[]> = {}
tasks.forEach(t => {
if (!t.next_due_date) return
const key = t.next_due_date.substring(0, 10)
if (!tasksByDate[key]) tasksByDate[key] = []
tasksByDate[key].push(t)
})
const prev = () => setCurrentMonth(new Date(year, month - 1, 1))
const next = () => setCurrentMonth(new Date(year, month + 1, 1))
const today = new Date().toISOString().substring(0, 10)
return (
<div>
<div className="flex items-center justify-between mb-4">
<button onClick={prev} className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">&larr; Vorher</button>
<h3 className="text-lg font-semibold text-gray-900">{monthLabel}</h3>
<button onClick={next} className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Weiter &rarr;</button>
</div>
<div className="grid grid-cols-7 gap-px bg-gray-200 rounded-xl overflow-hidden border border-gray-200">
{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map(d => (
<div key={d} className="bg-gray-50 p-2 text-xs font-medium text-gray-500 text-center">{d}</div>
))}
{Array.from({ length: startOffset }).map((_, i) => (
<div key={`empty-${i}`} className="bg-white p-2 min-h-[80px]"></div>
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const dayTasks = tasksByDate[dateStr] || []
const isToday = dateStr === today
return (
<div key={day} className={`bg-white p-2 min-h-[80px] ${isToday ? 'ring-2 ring-purple-400 ring-inset' : ''}`}>
<span className={`text-xs font-medium ${isToday ? 'text-purple-600' : 'text-gray-500'}`}>{day}</span>
<div className="mt-1 space-y-0.5">
{dayTasks.slice(0, 3).map(t => {
const days = daysUntil(t.next_due_date)
let dotColor = 'bg-gray-400'
if (t.status === 'completed') dotColor = 'bg-green-500'
else if (days !== null && days < 0) dotColor = 'bg-red-500'
else if (days !== null && days <= 7) dotColor = 'bg-orange-500'
return (
<div key={t.id} className="flex items-center gap-1" title={t.title}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotColor}`}></span>
<span className="text-[10px] text-gray-600 truncate">{t.title}</span>
</div>
)
})}
{dayTasks.length > 3 && (
<span className="text-[10px] text-gray-400">+{dayTasks.length - 3} mehr</span>
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { useState } from 'react'
import type { ProcessTask, CompleteFormData } from './types'
import { EMPTY_COMPLETE } from './types'
export function CompleteModal({
task,
onClose,
onComplete,
}: {
task: ProcessTask
onClose: () => void
onComplete: (data: CompleteFormData) => Promise<void>
}) {
const [form, setForm] = useState<CompleteFormData>({ ...EMPTY_COMPLETE })
const [saving, setSaving] = useState(false)
const handleSave = async () => {
setSaving(true)
try {
await onComplete(form)
onClose()
} catch {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900">Aufgabe erledigen</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">{'\u2715'}</button>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-gray-600 font-medium">{task.title}</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Erledigt von</label>
<input type="text" value={form.completed_by}
onChange={e => setForm(prev => ({ ...prev, completed_by: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Name / Rolle" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ergebnis</label>
<textarea value={form.result} onChange={e => setForm(prev => ({ ...prev, result: e.target.value }))}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Ergebnis der Pruefung / Massnahme..." />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
<textarea value={form.notes} onChange={e => setForm(prev => ({ ...prev, notes: e.target.value }))}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Zusaetzliche Hinweise..." />
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={handleSave} disabled={saving}
className="px-5 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Als erledigt markieren'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { useState } from 'react'
import type { ProcessTask } from './types'
export function SkipModal({
task,
onClose,
onSkip,
}: {
task: ProcessTask
onClose: () => void
onSkip: (reason: string) => Promise<void>
}) {
const [reason, setReason] = useState('')
const [saving, setSaving] = useState(false)
const handleSkip = async () => {
if (!reason.trim()) return
setSaving(true)
try {
await onSkip(reason)
onClose()
} catch {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900">Aufgabe ueberspringen</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">{'\u2715'}</button>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-gray-600 font-medium">{task.title}</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Begruendung *</label>
<textarea value={reason} onChange={e => setReason(e.target.value)} rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Warum wird diese Aufgabe uebersprungen?" />
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={handleSkip} disabled={saving || !reason.trim()}
className="px-5 py-2 text-sm bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 disabled:opacity-50">
{saving ? 'Speichern...' : 'Ueberspringen'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,165 @@
'use client'
import { useState, useEffect } from 'react'
import {
ProcessTask, HistoryEntry, API,
CATEGORY_LABELS, CATEGORY_COLORS, PRIORITY_LABELS, PRIORITY_COLORS,
STATUS_LABELS, STATUS_COLORS, FREQUENCY_LABELS,
formatDate, dueLabel, dueLabelColor,
} from './types'
export function TaskDetailModal({
task,
onClose,
onComplete,
onSkip,
onEdit,
onDelete,
}: {
task: ProcessTask
onClose: () => void
onComplete: (task: ProcessTask) => void
onSkip: (task: ProcessTask) => void
onEdit: (task: ProcessTask) => void
onDelete: (id: string) => Promise<void>
}) {
const [history, setHistory] = useState<HistoryEntry[]>([])
const [loadingHistory, setLoadingHistory] = useState(true)
useEffect(() => {
loadHistory()
}, [task.id]) // eslint-disable-line react-hooks/exhaustive-deps
const loadHistory = async () => {
try {
const res = await fetch(`${API}/${task.id}/history`)
if (res.ok) { const data = await res.json(); setHistory(data.history || []) }
} catch { /* ignore */ }
setLoadingHistory(false)
}
const handleDelete = async () => {
if (!confirm('Aufgabe wirklich loeschen?')) return
await onDelete(task.id)
onClose()
}
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto">
<div className="flex items-start justify-between p-6 border-b gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-0.5 text-xs rounded-full ${CATEGORY_COLORS[task.category] || 'bg-gray-100 text-gray-600'}`}>
{CATEGORY_LABELS[task.category] || task.category}
</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${PRIORITY_COLORS[task.priority]}`}>
{PRIORITY_LABELS[task.priority]}
</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[task.status]}`}>
{STATUS_LABELS[task.status]}
</span>
</div>
<h2 className="text-base font-semibold text-gray-900">{task.title}</h2>
<p className="text-xs text-gray-400 mt-0.5">{task.task_code}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 flex-shrink-0">{'\u2715'}</button>
</div>
<div className="p-6 space-y-4 text-sm">
{task.description && <p className="text-gray-700 whitespace-pre-wrap">{task.description}</p>}
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-gray-500">Frequenz</span>
<p className="font-medium text-gray-900">{FREQUENCY_LABELS[task.frequency] || task.frequency}</p>
</div>
<div>
<span className="text-gray-500">Faellig</span>
<p className={`font-medium ${dueLabelColor(task.next_due_date)}`}>
{task.next_due_date ? `${formatDate(task.next_due_date)} (${dueLabel(task.next_due_date)})` : '\u2014'}
</p>
</div>
<div>
<span className="text-gray-500">Zustaendig</span>
<p className="font-medium text-gray-900">{task.assigned_to || '\u2014'}</p>
</div>
<div>
<span className="text-gray-500">Team</span>
<p className="font-medium text-gray-900">{task.responsible_team || '\u2014'}</p>
</div>
{task.linked_module && (
<div>
<span className="text-gray-500">Modul</span>
<p className="font-medium text-purple-700">{task.linked_module}</p>
</div>
)}
{task.last_completed_at && (
<div>
<span className="text-gray-500">Zuletzt erledigt</span>
<p className="font-medium text-gray-900">{formatDate(task.last_completed_at)}</p>
</div>
)}
</div>
{task.notes && (
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500 text-xs">Notizen</span>
<p className="text-gray-700 mt-1">{task.notes}</p>
</div>
)}
<div className="border-t pt-4">
<h3 className="font-semibold text-gray-900 mb-2">Verlauf</h3>
{loadingHistory ? (
<div className="animate-pulse space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
) : history.length === 0 ? (
<p className="text-gray-400 text-sm">Noch keine Eintraege</p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{history.map(h => (
<div key={h.id} className="flex items-start gap-2 text-xs bg-gray-50 rounded-lg p-2">
<span className={`px-1.5 py-0.5 rounded ${h.status === 'completed' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'}`}>
{h.status === 'completed' ? 'Erledigt' : 'Uebersprungen'}
</span>
<div className="flex-1">
<p className="text-gray-600">{formatDate(h.completed_at)} {h.completed_by ? `von ${h.completed_by}` : ''}</p>
{h.result && <p className="text-gray-700 mt-0.5">{h.result}</p>}
{h.notes && <p className="text-gray-500 mt-0.5">{h.notes}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 p-6 border-t flex-wrap">
{task.status !== 'completed' && (
<>
<button onClick={() => { onClose(); onComplete(task) }}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
Erledigen
</button>
<button onClick={() => { onClose(); onSkip(task) }}
className="px-3 py-1.5 text-sm bg-yellow-500 text-white rounded-lg hover:bg-yellow-600">
Ueberspringen
</button>
</>
)}
<button onClick={() => { onClose(); onEdit(task) }}
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg border">
Bearbeiten
</button>
<button onClick={handleDelete}
className="px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 rounded-lg border border-red-200 ml-auto">
Loeschen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useState } from 'react'
import { EMPTY_FORM, CATEGORY_LABELS, PRIORITY_LABELS, FREQUENCY_LABELS } from './types'
import type { TaskFormData } from './types'
export function TaskFormModal({
initial,
onClose,
onSave,
}: {
initial?: Partial<TaskFormData>
onClose: () => void
onSave: (data: TaskFormData) => Promise<void>
}) {
const [form, setForm] = useState<TaskFormData>({ ...EMPTY_FORM, ...initial })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const update = (field: keyof TaskFormData, value: string | number) =>
setForm(prev => ({ ...prev, [field]: value }))
const handleSave = async () => {
if (!form.title.trim()) { setError('Titel ist erforderlich'); return }
if (!form.task_code.trim()) { setError('Task-Code ist erforderlich'); return }
setSaving(true)
setError(null)
try {
await onSave(form)
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900">
{initial?.title ? 'Aufgabe bearbeiten' : 'Neue Aufgabe erstellen'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">{'\u2715'}</button>
</div>
<div className="p-6 space-y-4">
{error && <div className="text-red-600 text-sm bg-red-50 rounded-lg p-3">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Task-Code *</label>
<input type="text" value={form.task_code} onChange={e => update('task_code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="z.B. DSGVO-VVT-REVIEW" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select value={form.category} onChange={e => update('category', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input type="text" value={form.title} onChange={e => update('title', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="z.B. VVT-Review und Aktualisierung" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea value={form.description} onChange={e => update('description', e.target.value)} rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Detaillierte Beschreibung..." />
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet</label>
<select value={form.priority} onChange={e => update('priority', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
{Object.entries(PRIORITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Frequenz</label>
<select value={form.frequency} onChange={e => update('frequency', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
{Object.entries(FREQUENCY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Faellig am</label>
<input type="date" value={form.next_due_date} onChange={e => update('next_due_date', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Zustaendig</label>
<input type="text" value={form.assigned_to} onChange={e => update('assigned_to', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="z.B. DSB, CISO" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Team</label>
<input type="text" value={form.responsible_team} onChange={e => update('responsible_team', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="z.B. IT-Sicherheit" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepftes Modul</label>
<input type="text" value={form.linked_module} onChange={e => update('linked_module', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="z.B. vvt, tom, dsfa" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Erinnerung (Tage vorher)</label>
<input type="number" value={form.due_reminder_days}
onChange={e => update('due_reminder_days', parseInt(e.target.value) || 14)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" min={0} max={90} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
<textarea value={form.notes} onChange={e => update('notes', e.target.value)} rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="Interne Hinweise..." />
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button onClick={handleSave} disabled={saving}
className="px-5 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
'use client'
import { useEffect } from 'react'
export function Toast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => {
const t = setTimeout(onClose, 3000)
return () => clearTimeout(t)
}, [onClose])
return (
<div className="fixed bottom-6 right-6 z-[60] bg-gray-900 text-white px-5 py-3 rounded-xl shadow-xl text-sm animate-slide-up">
{message}
</div>
)
}

View File

@@ -0,0 +1,179 @@
export interface ProcessTask {
id: string
tenant_id: string
project_id: string | null
task_code: string
title: string
description: string | null
category: string
priority: string
frequency: string
assigned_to: string | null
responsible_team: string | null
linked_control_ids: string[]
linked_module: string | null
last_completed_at: string | null
next_due_date: string | null
due_reminder_days: number
status: string
completion_date: string | null
completion_result: string | null
completion_evidence_id: string | null
follow_up_actions: string[]
is_seed: boolean
notes: string | null
tags: string[]
created_at: string
updated_at: string
}
export interface TaskStats {
total: number
by_status: Record<string, number>
by_category: Record<string, number>
overdue_count: number
due_7_days: number
due_14_days: number
due_30_days: number
}
export interface TaskFormData {
task_code: string
title: string
description: string
category: string
priority: string
frequency: string
assigned_to: string
responsible_team: string
linked_module: string
next_due_date: string
due_reminder_days: number
notes: string
}
export interface CompleteFormData {
completed_by: string
result: string
notes: string
}
export interface HistoryEntry {
id: string
task_id: string
completed_by: string | null
completed_at: string
result: string | null
evidence_id: string | null
notes: string | null
status: string
}
export const EMPTY_FORM: TaskFormData = {
task_code: '',
title: '',
description: '',
category: 'dsgvo',
priority: 'medium',
frequency: 'yearly',
assigned_to: '',
responsible_team: '',
linked_module: '',
next_due_date: '',
due_reminder_days: 14,
notes: '',
}
export const EMPTY_COMPLETE: CompleteFormData = {
completed_by: '',
result: '',
notes: '',
}
export const API = '/api/sdk/v1/compliance/process-tasks'
export const CATEGORY_LABELS: Record<string, string> = {
dsgvo: 'DSGVO', nis2: 'NIS2', bsi: 'BSI',
iso27001: 'ISO 27001', ai_act: 'AI Act', internal: 'Intern',
}
export const CATEGORY_COLORS: Record<string, string> = {
dsgvo: 'bg-blue-100 text-blue-700',
nis2: 'bg-purple-100 text-purple-700',
bsi: 'bg-green-100 text-green-700',
iso27001: 'bg-indigo-100 text-indigo-700',
ai_act: 'bg-orange-100 text-orange-700',
internal: 'bg-gray-100 text-gray-600',
}
export const PRIORITY_LABELS: Record<string, string> = {
critical: 'Kritisch', high: 'Hoch', medium: 'Mittel', low: 'Niedrig',
}
export const PRIORITY_COLORS: Record<string, string> = {
critical: 'bg-red-100 text-red-700',
high: 'bg-orange-100 text-orange-700',
medium: 'bg-yellow-100 text-yellow-700',
low: 'bg-green-100 text-green-700',
}
export const STATUS_LABELS: Record<string, string> = {
pending: 'Ausstehend',
in_progress: 'In Bearbeitung',
completed: 'Erledigt',
overdue: 'Ueberfaellig',
skipped: 'Uebersprungen',
}
export const STATUS_COLORS: Record<string, string> = {
pending: 'bg-gray-100 text-gray-600',
in_progress: 'bg-blue-100 text-blue-700',
completed: 'bg-green-100 text-green-700',
overdue: 'bg-red-100 text-red-700',
skipped: 'bg-yellow-100 text-yellow-700',
}
export const STATUS_ICONS: Record<string, string> = {
pending: '\u25CB',
in_progress: '\u25D4',
completed: '\u2714',
overdue: '\u26A0',
skipped: '\u2192',
}
export const FREQUENCY_LABELS: Record<string, string> = {
weekly: 'Woechentlich',
monthly: 'Monatlich',
quarterly: 'Quartalsweise',
semi_annual: 'Halbjaehrlich',
yearly: 'Jaehrlich',
once: 'Einmalig',
}
export function formatDate(d: string | null): string {
if (!d) return '\u2014'
return new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
export function daysUntil(d: string | null): number | null {
if (!d) return null
return Math.ceil((new Date(d).getTime() - Date.now()) / 86400000)
}
export function dueLabel(d: string | null): string {
const days = daysUntil(d)
if (days === null) return '\u2014'
if (days < 0) return `${Math.abs(days)} Tage ueberfaellig`
if (days === 0) return 'Heute faellig'
if (days === 1) return 'Morgen faellig'
return `In ${days} Tagen`
}
export function dueLabelColor(d: string | null): string {
const days = daysUntil(d)
if (days === null) return 'text-gray-400'
if (days < 0) return 'text-red-600 font-semibold'
if (days <= 7) return 'text-orange-600'
if (days <= 30) return 'text-yellow-600'
return 'text-green-600'
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { ProcessTask, TaskStats, TaskFormData, CompleteFormData, API } from '../_components/types'
export function useProcessTasks() {
const [activeTab, setActiveTab] = useState<'overview' | 'all' | 'calendar'>('overview')
const [tasks, setTasks] = useState<ProcessTask[]>([])
const [totalTasks, setTotalTasks] = useState(0)
const [stats, setStats] = useState<TaskStats | null>(null)
const [upcomingTasks, setUpcomingTasks] = useState<ProcessTask[]>([])
const [filterStatus, setFilterStatus] = useState('')
const [filterCategory, setFilterCategory] = useState('')
const [filterFrequency, setFilterFrequency] = useState('')
const [page, setPage] = useState(0)
const PAGE_SIZE = 25
const [loading, setLoading] = useState(true)
const [toast, setToast] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editTask, setEditTask] = useState<ProcessTask | null>(null)
const [detailTask, setDetailTask] = useState<ProcessTask | null>(null)
const [completeTask, setCompleteTask] = useState<ProcessTask | null>(null)
const [skipTask, setSkipTask] = useState<ProcessTask | null>(null)
const loadStats = useCallback(async () => {
try {
const res = await fetch(`${API}/stats`)
if (res.ok) setStats(await res.json())
} catch { /* ignore */ }
}, [])
const loadUpcoming = useCallback(async () => {
try {
const res = await fetch(`${API}/upcoming?days=30`)
if (res.ok) { const data = await res.json(); setUpcomingTasks(data.tasks || []) }
} catch { /* ignore */ }
}, [])
const loadTasks = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterStatus) params.set('status', filterStatus)
if (filterCategory) params.set('category', filterCategory)
if (filterFrequency) params.set('frequency', filterFrequency)
params.set('limit', String(PAGE_SIZE))
params.set('offset', String(page * PAGE_SIZE))
const res = await fetch(`${API}?${params}`)
if (res.ok) { const data = await res.json(); setTasks(data.tasks || []); setTotalTasks(data.total || 0) }
} catch { /* ignore */ }
setLoading(false)
}, [filterStatus, filterCategory, filterFrequency, page])
useEffect(() => {
loadStats()
loadUpcoming()
loadTasks()
}, [loadStats, loadUpcoming, loadTasks])
const handleCreate = async (data: TaskFormData) => {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || 'Fehler beim Erstellen')
}
setToast('Aufgabe erstellt')
loadTasks(); loadStats(); loadUpcoming()
}
const handleUpdate = async (data: TaskFormData) => {
if (!editTask) return
const res = await fetch(`${API}/${editTask.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || 'Fehler beim Speichern')
}
setEditTask(null)
setToast('Aufgabe aktualisiert')
loadTasks(); loadStats(); loadUpcoming()
}
const handleComplete = async (data: CompleteFormData) => {
if (!completeTask) return
const res = await fetch(`${API}/${completeTask.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Fehler')
setCompleteTask(null)
setToast('Aufgabe als erledigt markiert')
loadTasks(); loadStats(); loadUpcoming()
}
const handleSkip = async (reason: string) => {
if (!skipTask) return
const res = await fetch(`${API}/${skipTask.id}/skip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
})
if (!res.ok) throw new Error('Fehler')
setSkipTask(null)
setToast('Aufgabe uebersprungen')
loadTasks(); loadStats(); loadUpcoming()
}
const handleDelete = async (id: string) => {
const res = await fetch(`${API}/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Fehler beim Loeschen')
setToast('Aufgabe geloescht')
loadTasks(); loadStats(); loadUpcoming()
}
const handleSeed = async () => {
const res = await fetch(`${API}/seed`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
setToast(`${data.seeded} Standard-Aufgaben erstellt`)
loadTasks(); loadStats(); loadUpcoming()
}
}
const totalPages = Math.ceil(totalTasks / PAGE_SIZE)
return {
activeTab, setActiveTab,
tasks, totalTasks, stats, upcomingTasks,
filterStatus, setFilterStatus,
filterCategory, setFilterCategory,
filterFrequency, setFilterFrequency,
page, setPage, PAGE_SIZE, totalPages,
loading, toast, setToast,
showForm, setShowForm,
editTask, setEditTask,
detailTask, setDetailTask,
completeTask, setCompleteTask,
skipTask, setSkipTask,
handleCreate, handleUpdate, handleComplete, handleSkip, handleDelete, handleSeed,
}
}

File diff suppressed because it is too large Load Diff