All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
687 lines
25 KiB
TypeScript
687 lines
25 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useEffect, useRef } from 'react'
|
||
import { useSDK, Evidence as SDKEvidence, EvidenceType } from '@/lib/sdk'
|
||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||
|
||
// =============================================================================
|
||
// TYPES
|
||
// =============================================================================
|
||
|
||
type DisplayEvidenceType = 'document' | 'screenshot' | 'log' | 'audit-report' | 'certificate'
|
||
type DisplayFormat = 'pdf' | 'image' | 'text' | 'json'
|
||
type DisplayStatus = 'valid' | 'expired' | 'pending-review'
|
||
|
||
interface DisplayEvidence {
|
||
id: string
|
||
name: string
|
||
description: string
|
||
displayType: DisplayEvidenceType
|
||
format: DisplayFormat
|
||
controlId: string
|
||
linkedRequirements: string[]
|
||
linkedControls: string[]
|
||
uploadedBy: string
|
||
uploadedAt: Date
|
||
validFrom: Date
|
||
validUntil: Date | null
|
||
status: DisplayStatus
|
||
fileSize: string
|
||
fileUrl: string | null
|
||
}
|
||
|
||
// =============================================================================
|
||
// HELPER FUNCTIONS
|
||
// =============================================================================
|
||
|
||
function mapEvidenceTypeToDisplay(type: EvidenceType): DisplayEvidenceType {
|
||
switch (type) {
|
||
case 'DOCUMENT': return 'document'
|
||
case 'SCREENSHOT': return 'screenshot'
|
||
case 'LOG': return 'log'
|
||
case 'CERTIFICATE': return 'certificate'
|
||
case 'AUDIT_REPORT': return 'audit-report'
|
||
default: return 'document'
|
||
}
|
||
}
|
||
|
||
function getEvidenceStatus(validUntil: Date | null): DisplayStatus {
|
||
if (!validUntil) return 'pending-review'
|
||
const now = new Date()
|
||
if (validUntil < now) return 'expired'
|
||
return 'valid'
|
||
}
|
||
|
||
// =============================================================================
|
||
// FALLBACK TEMPLATES
|
||
// =============================================================================
|
||
|
||
interface EvidenceTemplate {
|
||
id: string
|
||
name: string
|
||
description: string
|
||
type: EvidenceType
|
||
displayType: DisplayEvidenceType
|
||
format: DisplayFormat
|
||
controlId: string
|
||
linkedRequirements: string[]
|
||
linkedControls: string[]
|
||
uploadedBy: string
|
||
validityDays: number
|
||
fileSize: string
|
||
}
|
||
|
||
const evidenceTemplates: EvidenceTemplate[] = [
|
||
{
|
||
id: 'ev-dse-001',
|
||
name: 'Datenschutzerklaerung v2.3',
|
||
description: 'Aktuelle Datenschutzerklaerung fuer Website und App',
|
||
type: 'DOCUMENT',
|
||
displayType: 'document',
|
||
format: 'pdf',
|
||
controlId: 'ctrl-org-001',
|
||
linkedRequirements: ['req-gdpr-13', 'req-gdpr-14'],
|
||
linkedControls: ['ctrl-org-001'],
|
||
uploadedBy: 'DSB',
|
||
validityDays: 365,
|
||
fileSize: '245 KB',
|
||
},
|
||
{
|
||
id: 'ev-pentest-001',
|
||
name: 'Penetrationstest Report Q4/2024',
|
||
description: 'Externer Penetrationstest durch Security-Partner',
|
||
type: 'AUDIT_REPORT',
|
||
displayType: 'audit-report',
|
||
format: 'pdf',
|
||
controlId: 'ctrl-tom-001',
|
||
linkedRequirements: ['req-gdpr-32', 'req-iso-a12'],
|
||
linkedControls: ['ctrl-tom-001', 'ctrl-tom-002', 'ctrl-det-001'],
|
||
uploadedBy: 'IT Security Team',
|
||
validityDays: 365,
|
||
fileSize: '2.1 MB',
|
||
},
|
||
{
|
||
id: 'ev-iso-cert',
|
||
name: 'ISO 27001 Zertifikat',
|
||
description: 'Zertifizierung des ISMS',
|
||
type: 'CERTIFICATE',
|
||
displayType: 'certificate',
|
||
format: 'pdf',
|
||
controlId: 'ctrl-tom-001',
|
||
linkedRequirements: ['req-iso-4.1', 'req-iso-5.1'],
|
||
linkedControls: [],
|
||
uploadedBy: 'QM Abteilung',
|
||
validityDays: 365,
|
||
fileSize: '156 KB',
|
||
},
|
||
{
|
||
id: 'ev-schulung-001',
|
||
name: 'Schulungsnachweis Datenschutz 2024',
|
||
description: 'Teilnehmerliste und Schulungsinhalt',
|
||
type: 'DOCUMENT',
|
||
displayType: 'document',
|
||
format: 'pdf',
|
||
controlId: 'ctrl-org-001',
|
||
linkedRequirements: ['req-gdpr-39'],
|
||
linkedControls: ['ctrl-org-001'],
|
||
uploadedBy: 'HR Team',
|
||
validityDays: 365,
|
||
fileSize: '890 KB',
|
||
},
|
||
{
|
||
id: 'ev-rbac-001',
|
||
name: 'Access Control Screenshot',
|
||
description: 'Nachweis der RBAC-Konfiguration',
|
||
type: 'SCREENSHOT',
|
||
displayType: 'screenshot',
|
||
format: 'image',
|
||
controlId: 'ctrl-tom-001',
|
||
linkedRequirements: ['req-gdpr-32'],
|
||
linkedControls: ['ctrl-tom-001'],
|
||
uploadedBy: 'Admin',
|
||
validityDays: 0,
|
||
fileSize: '1.2 MB',
|
||
},
|
||
{
|
||
id: 'ev-log-001',
|
||
name: 'Audit Log Export',
|
||
description: 'Monatlicher Audit-Log Export',
|
||
type: 'LOG',
|
||
displayType: 'log',
|
||
format: 'json',
|
||
controlId: 'ctrl-det-001',
|
||
linkedRequirements: ['req-gdpr-32'],
|
||
linkedControls: ['ctrl-det-001'],
|
||
uploadedBy: 'System',
|
||
validityDays: 90,
|
||
fileSize: '4.5 MB',
|
||
},
|
||
]
|
||
|
||
// =============================================================================
|
||
// COMPONENTS
|
||
// =============================================================================
|
||
|
||
function EvidenceCard({ evidence, onDelete, onView, onDownload }: { evidence: DisplayEvidence; onDelete: () => void; onView: () => void; onDownload: () => void }) {
|
||
const typeIcons = {
|
||
document: (
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
),
|
||
screenshot: (
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||
</svg>
|
||
),
|
||
log: (
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||
</svg>
|
||
),
|
||
'audit-report': (
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
),
|
||
certificate: (
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||
</svg>
|
||
),
|
||
}
|
||
|
||
const statusColors = {
|
||
valid: 'bg-green-100 text-green-700 border-green-200',
|
||
expired: 'bg-red-100 text-red-700 border-red-200',
|
||
'pending-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||
}
|
||
|
||
const statusLabels = {
|
||
valid: 'Gueltig',
|
||
expired: 'Abgelaufen',
|
||
'pending-review': 'Pruefung ausstehend',
|
||
}
|
||
|
||
return (
|
||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||
evidence.status === 'expired' ? 'border-red-200' :
|
||
evidence.status === 'pending-review' ? 'border-yellow-200' : 'border-gray-200'
|
||
}`}>
|
||
<div className="flex items-start gap-4">
|
||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||
evidence.displayType === 'certificate' ? 'bg-yellow-100 text-yellow-600' :
|
||
evidence.displayType === 'audit-report' ? 'bg-purple-100 text-purple-600' :
|
||
evidence.displayType === 'screenshot' ? 'bg-blue-100 text-blue-600' :
|
||
evidence.displayType === 'log' ? 'bg-green-100 text-green-600' :
|
||
'bg-gray-100 text-gray-600'
|
||
}`}>
|
||
{typeIcons[evidence.displayType]}
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-lg font-semibold text-gray-900">{evidence.name}</h3>
|
||
<span className={`px-3 py-1 text-xs rounded-full ${statusColors[evidence.status]}`}>
|
||
{statusLabels[evidence.status]}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm text-gray-500 mt-1">{evidence.description}</p>
|
||
|
||
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
|
||
<span>Hochgeladen: {evidence.uploadedAt.toLocaleDateString('de-DE')}</span>
|
||
{evidence.validUntil && (
|
||
<span className={evidence.status === 'expired' ? 'text-red-600' : ''}>
|
||
Gueltig bis: {evidence.validUntil.toLocaleDateString('de-DE')}
|
||
</span>
|
||
)}
|
||
<span>{evidence.fileSize}</span>
|
||
</div>
|
||
|
||
<div className="mt-3 flex items-center gap-2 flex-wrap">
|
||
{evidence.linkedRequirements.map(req => (
|
||
<span key={req} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||
{req}
|
||
</span>
|
||
))}
|
||
{evidence.linkedControls.map(ctrl => (
|
||
<span key={ctrl} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
|
||
{ctrl}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||
<span className="text-sm text-gray-500">Hochgeladen von: {evidence.uploadedBy}</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={onView}
|
||
disabled={!evidence.fileUrl}
|
||
className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
Anzeigen
|
||
</button>
|
||
<button
|
||
onClick={onDownload}
|
||
disabled={!evidence.fileUrl}
|
||
className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
Herunterladen
|
||
</button>
|
||
<button
|
||
onClick={onDelete}
|
||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||
>
|
||
Loeschen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function LoadingSkeleton() {
|
||
return (
|
||
<div className="space-y-4">
|
||
{[1, 2, 3].map(i => (
|
||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||
<div className="flex items-start gap-4">
|
||
<div className="w-12 h-12 bg-gray-200 rounded-lg" />
|
||
<div className="flex-1">
|
||
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||
<div className="h-4 w-full bg-gray-100 rounded" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// =============================================================================
|
||
// MAIN PAGE
|
||
// =============================================================================
|
||
|
||
export default function EvidencePage() {
|
||
const { state, dispatch } = useSDK()
|
||
const [filter, setFilter] = useState<string>('all')
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [uploading, setUploading] = useState(false)
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize] = useState(20)
|
||
const [total, setTotal] = useState(0)
|
||
|
||
// Fetch evidence from backend on mount and when page changes
|
||
useEffect(() => {
|
||
const fetchEvidence = async () => {
|
||
try {
|
||
setLoading(true)
|
||
const res = await fetch(`/api/sdk/v1/compliance/evidence?page=${page}&limit=${pageSize}`)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
if (data.total !== undefined) setTotal(data.total)
|
||
const backendEvidence = data.evidence || data
|
||
if (Array.isArray(backendEvidence) && backendEvidence.length > 0) {
|
||
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => ({
|
||
id: (e.id || '') as string,
|
||
controlId: (e.control_id || '') as string,
|
||
type: ((e.evidence_type || 'DOCUMENT') as string).toUpperCase() as EvidenceType,
|
||
name: (e.title || e.name || '') as string,
|
||
description: (e.description || '') as string,
|
||
fileUrl: (e.artifact_url || null) as string | null,
|
||
validFrom: e.valid_from ? new Date(e.valid_from as string) : new Date(),
|
||
validUntil: e.valid_until ? new Date(e.valid_until as string) : null,
|
||
uploadedBy: (e.uploaded_by || 'System') as string,
|
||
uploadedAt: e.created_at ? new Date(e.created_at as string) : new Date(),
|
||
}))
|
||
dispatch({ type: 'SET_STATE', payload: { evidence: mapped } })
|
||
setError(null)
|
||
return
|
||
}
|
||
}
|
||
loadFromTemplates()
|
||
} catch {
|
||
loadFromTemplates()
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadFromTemplates = () => {
|
||
if (state.evidence.length > 0) return
|
||
if (state.controls.length === 0) return
|
||
|
||
const relevantEvidence = evidenceTemplates.filter(e =>
|
||
state.controls.some(c => c.id === e.controlId || e.linkedControls.includes(c.id))
|
||
)
|
||
|
||
const now = new Date()
|
||
relevantEvidence.forEach(template => {
|
||
const validFrom = new Date(now)
|
||
validFrom.setMonth(validFrom.getMonth() - 1)
|
||
|
||
const validUntil = template.validityDays > 0
|
||
? new Date(validFrom.getTime() + template.validityDays * 24 * 60 * 60 * 1000)
|
||
: null
|
||
|
||
const sdkEvidence: SDKEvidence = {
|
||
id: template.id,
|
||
controlId: template.controlId,
|
||
type: template.type,
|
||
name: template.name,
|
||
description: template.description,
|
||
fileUrl: null,
|
||
validFrom,
|
||
validUntil,
|
||
uploadedBy: template.uploadedBy,
|
||
uploadedAt: validFrom,
|
||
}
|
||
dispatch({ type: 'ADD_EVIDENCE', payload: sdkEvidence })
|
||
})
|
||
}
|
||
|
||
fetchEvidence()
|
||
}, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Convert SDK evidence to display evidence
|
||
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
|
||
const template = evidenceTemplates.find(t => t.id === ev.id)
|
||
|
||
return {
|
||
id: ev.id,
|
||
name: ev.name,
|
||
description: ev.description,
|
||
displayType: mapEvidenceTypeToDisplay(ev.type),
|
||
format: template?.format || 'pdf',
|
||
controlId: ev.controlId,
|
||
linkedRequirements: template?.linkedRequirements || [],
|
||
linkedControls: template?.linkedControls || [ev.controlId],
|
||
uploadedBy: ev.uploadedBy,
|
||
uploadedAt: ev.uploadedAt,
|
||
validFrom: ev.validFrom,
|
||
validUntil: ev.validUntil,
|
||
status: getEvidenceStatus(ev.validUntil),
|
||
fileSize: template?.fileSize || 'Unbekannt',
|
||
fileUrl: ev.fileUrl,
|
||
}
|
||
})
|
||
|
||
const filteredEvidence = filter === 'all'
|
||
? displayEvidence
|
||
: displayEvidence.filter(e => e.status === filter || e.displayType === filter)
|
||
|
||
const validCount = displayEvidence.filter(e => e.status === 'valid').length
|
||
const expiredCount = displayEvidence.filter(e => e.status === 'expired').length
|
||
const pendingCount = displayEvidence.filter(e => e.status === 'pending-review').length
|
||
|
||
const handleDelete = async (evidenceId: string) => {
|
||
if (!confirm('Moechten Sie diesen Nachweis wirklich loeschen?')) return
|
||
|
||
dispatch({ type: 'DELETE_EVIDENCE', payload: evidenceId })
|
||
|
||
try {
|
||
await fetch(`/api/sdk/v1/compliance/evidence/${evidenceId}`, {
|
||
method: 'DELETE',
|
||
})
|
||
} catch {
|
||
// Silently fail — SDK state is already updated
|
||
}
|
||
}
|
||
|
||
const handleUpload = async (file: File) => {
|
||
setUploading(true)
|
||
setError(null)
|
||
|
||
try {
|
||
// Use the first control as default, or a generic one
|
||
const controlId = state.controls.length > 0 ? state.controls[0].id : 'GENERIC'
|
||
|
||
const params = new URLSearchParams({
|
||
control_id: controlId,
|
||
evidence_type: 'document',
|
||
title: file.name,
|
||
})
|
||
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
const res = await fetch(`/api/sdk/v1/compliance/evidence/upload?${params}`, {
|
||
method: 'POST',
|
||
body: formData,
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const errData = await res.json().catch(() => ({ error: 'Upload fehlgeschlagen' }))
|
||
throw new Error(errData.error || errData.detail || 'Upload fehlgeschlagen')
|
||
}
|
||
|
||
const data = await res.json()
|
||
|
||
// Add to SDK state
|
||
const newEvidence: SDKEvidence = {
|
||
id: data.id || `ev-${Date.now()}`,
|
||
controlId: controlId,
|
||
type: 'DOCUMENT',
|
||
name: file.name,
|
||
description: `Hochgeladen am ${new Date().toLocaleDateString('de-DE')}`,
|
||
fileUrl: data.artifact_url || null,
|
||
validFrom: new Date(),
|
||
validUntil: null,
|
||
uploadedBy: 'Aktueller Benutzer',
|
||
uploadedAt: new Date(),
|
||
}
|
||
dispatch({ type: 'ADD_EVIDENCE', payload: newEvidence })
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
|
||
} finally {
|
||
setUploading(false)
|
||
}
|
||
}
|
||
|
||
const handleView = (ev: DisplayEvidence) => {
|
||
if (ev.fileUrl) {
|
||
window.open(ev.fileUrl, '_blank')
|
||
} else {
|
||
alert('Keine Datei vorhanden')
|
||
}
|
||
}
|
||
|
||
const handleDownload = (ev: DisplayEvidence) => {
|
||
if (!ev.fileUrl) return
|
||
const a = document.createElement('a')
|
||
a.href = ev.fileUrl
|
||
a.download = ev.name
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
}
|
||
|
||
const handleUploadClick = () => {
|
||
fileInputRef.current?.click()
|
||
}
|
||
|
||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
handleUpload(file)
|
||
e.target.value = '' // Reset input
|
||
}
|
||
}
|
||
|
||
const stepInfo = STEP_EXPLANATIONS['evidence']
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Hidden file input */}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
className="hidden"
|
||
onChange={handleFileChange}
|
||
accept=".pdf,.doc,.docx,.png,.jpg,.jpeg,.json,.csv,.txt"
|
||
/>
|
||
|
||
{/* Step Header */}
|
||
<StepHeader
|
||
stepId="evidence"
|
||
title={stepInfo.title}
|
||
description={stepInfo.description}
|
||
explanation={stepInfo.explanation}
|
||
tips={stepInfo.tips}
|
||
>
|
||
<button
|
||
onClick={handleUploadClick}
|
||
disabled={uploading}
|
||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||
>
|
||
{uploading ? (
|
||
<>
|
||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||
</svg>
|
||
Wird hochgeladen...
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||
</svg>
|
||
Nachweis hochladen
|
||
</>
|
||
)}
|
||
</button>
|
||
</StepHeader>
|
||
|
||
{/* Error Banner */}
|
||
{error && (
|
||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||
<span>{error}</span>
|
||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Controls Alert */}
|
||
{state.controls.length === 0 && !loading && (
|
||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||
<div className="flex items-start gap-3">
|
||
<svg className="w-5 h-5 text-amber-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
<div>
|
||
<h4 className="font-medium text-amber-800">Keine Kontrollen definiert</h4>
|
||
<p className="text-sm text-amber-700 mt-1">
|
||
Bitte definieren Sie zuerst Kontrollen, um die zugehoerigen Nachweise zu laden.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Stats */}
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<div className="text-sm text-gray-500">Gesamt</div>
|
||
<div className="text-3xl font-bold text-gray-900">{displayEvidence.length}</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||
<div className="text-sm text-green-600">Gueltig</div>
|
||
<div className="text-3xl font-bold text-green-600">{validCount}</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||
<div className="text-sm text-red-600">Abgelaufen</div>
|
||
<div className="text-3xl font-bold text-red-600">{expiredCount}</div>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||
<div className="text-sm text-yellow-600">Pruefung ausstehend</div>
|
||
<div className="text-3xl font-bold text-yellow-600">{pendingCount}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter */}
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-sm text-gray-500">Filter:</span>
|
||
{['all', 'valid', 'expired', 'pending-review', 'document', 'certificate', 'audit-report'].map(f => (
|
||
<button
|
||
key={f}
|
||
onClick={() => setFilter(f)}
|
||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||
filter === f
|
||
? 'bg-purple-600 text-white'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
{f === 'all' ? 'Alle' :
|
||
f === 'valid' ? 'Gueltig' :
|
||
f === 'expired' ? 'Abgelaufen' :
|
||
f === 'pending-review' ? 'Ausstehend' :
|
||
f === 'document' ? 'Dokumente' :
|
||
f === 'certificate' ? 'Zertifikate' : 'Audit-Berichte'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Loading State */}
|
||
{loading && <LoadingSkeleton />}
|
||
|
||
{/* Evidence List */}
|
||
{!loading && (
|
||
<div className="space-y-4">
|
||
{filteredEvidence.map(ev => (
|
||
<EvidenceCard
|
||
key={ev.id}
|
||
evidence={ev}
|
||
onDelete={() => handleDelete(ev.id)}
|
||
onView={() => handleView(ev)}
|
||
onDownload={() => handleDownload(ev)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Pagination */}
|
||
{!loading && total > pageSize && (
|
||
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
|
||
<span className="text-sm text-gray-500">
|
||
Zeige {((page - 1) * pageSize) + 1}–{Math.min(page * pageSize, total)} von {total} Nachweisen
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||
disabled={page <= 1}
|
||
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
Zurueck
|
||
</button>
|
||
<span className="text-sm text-gray-700">
|
||
Seite {page} von {Math.ceil(total / pageSize)}
|
||
</span>
|
||
<button
|
||
onClick={() => setPage(p => Math.min(Math.ceil(total / pageSize), p + 1))}
|
||
disabled={page >= Math.ceil(total / pageSize)}
|
||
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
Weiter
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && filteredEvidence.length === 0 && state.controls.length > 0 && (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-900">Keine Nachweise gefunden</h3>
|
||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder laden Sie neue Nachweise hoch.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|