The admin-v2 application was incomplete in the repository. This commit restores all missing components: - Admin pages (76 pages): dashboard, ai, compliance, dsgvo, education, infrastructure, communication, development, onboarding, rbac - SDK pages (45 pages): tom, dsfa, vvt, loeschfristen, einwilligungen, vendor-compliance, tom-generator, dsr, and more - Developer portal (25 pages): API docs, SDK guides, frameworks - All components, lib files, hooks, and types - Updated package.json with all dependencies The issue was caused by incomplete initial repository state - the full admin-v2 codebase existed in backend/admin-v2 and docs-src/admin-v2 but was never fully synced to the main admin-v2 directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
776 lines
33 KiB
TypeScript
776 lines
33 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Audit Checklist Page - 476+ Requirements Interactive Checklist
|
|
*
|
|
* Features:
|
|
* - Session management (create, start, complete)
|
|
* - Paginated checklist with search & filters
|
|
* - Sign-off workflow with digital signatures
|
|
* - Progress tracking with statistics
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { PagePurpose } from '@/components/common/PagePurpose'
|
|
|
|
// Types
|
|
interface AuditSession {
|
|
id: string
|
|
name: string
|
|
auditor_name: string
|
|
auditor_email?: string
|
|
auditor_organization?: string
|
|
status: 'draft' | 'in_progress' | 'completed' | 'archived'
|
|
regulation_ids?: string[]
|
|
total_items: number
|
|
completed_items: number
|
|
compliant_count: number
|
|
non_compliant_count: number
|
|
completion_percentage: number
|
|
created_at: string
|
|
started_at?: string
|
|
completed_at?: string
|
|
}
|
|
|
|
interface ChecklistItem {
|
|
requirement_id: string
|
|
regulation_code: string
|
|
article: string
|
|
paragraph?: string
|
|
title: string
|
|
description?: string
|
|
current_result: string
|
|
notes?: string
|
|
is_signed: boolean
|
|
signed_at?: string
|
|
signed_by?: string
|
|
evidence_count: number
|
|
controls_mapped: number
|
|
implementation_status: string
|
|
priority: number
|
|
}
|
|
|
|
interface AuditStatistics {
|
|
total: number
|
|
compliant: number
|
|
compliant_with_notes: number
|
|
non_compliant: number
|
|
not_applicable: number
|
|
pending: number
|
|
completion_percentage: number
|
|
}
|
|
|
|
// Haupt-/Nebenabweichungen aus ISMS
|
|
interface FindingsData {
|
|
major_count: number // Hauptabweichungen (blockiert Zertifizierung)
|
|
minor_count: number // Nebenabweichungen (erfordert CAPA)
|
|
ofi_count: number // Verbesserungspotenziale
|
|
total: number
|
|
open_majors: number // Offene Hauptabweichungen
|
|
open_minors: number // Offene Nebenabweichungen
|
|
}
|
|
|
|
const RESULT_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
|
compliant: { bg: 'bg-green-100', text: 'text-green-700', label: 'Konform' },
|
|
compliant_notes: { bg: 'bg-green-50', text: 'text-green-600', label: 'Konform (mit Anm.)' },
|
|
non_compliant: { bg: 'bg-red-100', text: 'text-red-700', label: 'Nicht konform' },
|
|
not_applicable: { bg: 'bg-slate-100', text: 'text-slate-600', label: 'N/A' },
|
|
pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Ausstehend' },
|
|
}
|
|
|
|
export default function AuditChecklistPage() {
|
|
const [sessions, setSessions] = useState<AuditSession[]>([])
|
|
const [selectedSession, setSelectedSession] = useState<AuditSession | null>(null)
|
|
const [checklist, setChecklist] = useState<ChecklistItem[]>([])
|
|
const [statistics, setStatistics] = useState<AuditStatistics | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [checklistLoading, setChecklistLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [findings, setFindings] = useState<FindingsData | null>(null)
|
|
|
|
// Filters
|
|
const [search, setSearch] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<string>('')
|
|
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
|
const [page, setPage] = useState(1)
|
|
const [totalPages, setTotalPages] = useState(1)
|
|
|
|
// Modal states
|
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
const [showSignOffModal, setShowSignOffModal] = useState(false)
|
|
const [selectedItem, setSelectedItem] = useState<ChecklistItem | null>(null)
|
|
|
|
// New session form
|
|
const [newSession, setNewSession] = useState({
|
|
name: '',
|
|
auditor_name: '',
|
|
auditor_email: '',
|
|
auditor_organization: '',
|
|
regulation_codes: [] as string[],
|
|
})
|
|
|
|
useEffect(() => {
|
|
loadSessions()
|
|
loadFindings()
|
|
}, [])
|
|
|
|
const loadFindings = async () => {
|
|
try {
|
|
const res = await fetch('/api/admin/compliance/isms/findings/summary')
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setFindings(data)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load findings:', err)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (selectedSession) {
|
|
loadChecklist()
|
|
}
|
|
}, [selectedSession, page, statusFilter, regulationFilter, search])
|
|
|
|
const loadSessions = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch('/api/admin/audit/sessions')
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setSessions(data)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load sessions:', err)
|
|
setError('Sessions konnten nicht geladen werden')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const loadChecklist = async () => {
|
|
if (!selectedSession) return
|
|
setChecklistLoading(true)
|
|
try {
|
|
const params = new URLSearchParams({
|
|
page: page.toString(),
|
|
page_size: '50',
|
|
})
|
|
if (statusFilter) params.set('status_filter', statusFilter)
|
|
if (regulationFilter) params.set('regulation_filter', regulationFilter)
|
|
if (search) params.set('search', search)
|
|
|
|
const res = await fetch(`/api/admin/compliance/audit/checklist/${selectedSession.id}?${params}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setChecklist(data.items || [])
|
|
setStatistics(data.statistics)
|
|
setTotalPages(data.pagination?.total_pages || 1)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load checklist:', err)
|
|
} finally {
|
|
setChecklistLoading(false)
|
|
}
|
|
}
|
|
|
|
const createSession = async () => {
|
|
try {
|
|
const res = await fetch('/api/admin/audit/sessions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newSession),
|
|
})
|
|
if (res.ok) {
|
|
const session = await res.json()
|
|
setSessions([session, ...sessions])
|
|
setSelectedSession(session)
|
|
setShowCreateModal(false)
|
|
setNewSession({
|
|
name: '',
|
|
auditor_name: '',
|
|
auditor_email: '',
|
|
auditor_organization: '',
|
|
regulation_codes: [],
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create session:', err)
|
|
}
|
|
}
|
|
|
|
const startSession = async (sessionId: string) => {
|
|
try {
|
|
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/start`, {
|
|
method: 'PUT',
|
|
})
|
|
if (res.ok) {
|
|
loadSessions()
|
|
if (selectedSession?.id === sessionId) {
|
|
setSelectedSession({ ...selectedSession, status: 'in_progress' })
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to start session:', err)
|
|
}
|
|
}
|
|
|
|
const completeSession = async (sessionId: string) => {
|
|
try {
|
|
const res = await fetch(`/api/admin/audit/sessions/${sessionId}/complete`, {
|
|
method: 'PUT',
|
|
})
|
|
if (res.ok) {
|
|
loadSessions()
|
|
if (selectedSession?.id === sessionId) {
|
|
setSelectedSession({ ...selectedSession, status: 'completed' })
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to complete session:', err)
|
|
}
|
|
}
|
|
|
|
const signOffItem = async (result: string, notes: string, sign: boolean) => {
|
|
if (!selectedSession || !selectedItem) return
|
|
try {
|
|
const res = await fetch(
|
|
`/api/admin/compliance/audit/checklist/${selectedSession.id}/items/${selectedItem.requirement_id}`,
|
|
{
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ result, notes, sign }),
|
|
}
|
|
)
|
|
if (res.ok) {
|
|
loadChecklist()
|
|
loadSessions()
|
|
setShowSignOffModal(false)
|
|
setSelectedItem(null)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to sign off:', err)
|
|
}
|
|
}
|
|
|
|
const downloadPdf = async (sessionId: string) => {
|
|
window.open(`/api/admin/audit/sessions/${sessionId}/pdf`, '_blank')
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PagePurpose
|
|
title="Audit Checkliste"
|
|
purpose="Interaktive Checkliste mit 476+ Compliance-Anforderungen aus DSGVO, AI Act, CRA und BSI TR-03161. Erstellen Sie Audit-Sessions, bewerten Sie Anforderungen und generieren Sie Audit-Reports mit digitalen Signaturen."
|
|
audience={['Auditor', 'DSB', 'Compliance Officer']}
|
|
gdprArticles={['Art. 5 (Rechenschaftspflicht)', 'Art. 30 (Verzeichnis)', 'Art. 32 (Sicherheit)']}
|
|
architecture={{
|
|
services: ['Python Backend', 'PostgreSQL'],
|
|
databases: ['compliance_audit_sessions', 'compliance_audit_signoffs', 'compliance_requirements'],
|
|
}}
|
|
relatedPages={[
|
|
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Uebersicht & Dashboard' },
|
|
{ name: 'Audit Report', href: '/compliance/audit-report', description: 'PDF-Reports generieren' },
|
|
{ name: 'Controls', href: '/compliance/controls', description: 'Technische Massnahmen' },
|
|
]}
|
|
/>
|
|
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<p className="text-red-700">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Haupt-/Nebenabweichungen Uebersicht */}
|
|
{findings && (
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold text-slate-900">Audit Findings (ISMS)</h2>
|
|
<span className={`px-3 py-1 text-sm rounded-full ${
|
|
findings.open_majors > 0
|
|
? 'bg-red-100 text-red-700'
|
|
: 'bg-green-100 text-green-700'
|
|
}`}>
|
|
{findings.open_majors > 0 ? 'Zertifizierung blockiert' : 'Zertifizierungsfaehig'}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
<div className="text-center p-4 bg-red-50 rounded-lg border border-red-200">
|
|
<p className="text-3xl font-bold text-red-700">{findings.major_count}</p>
|
|
<p className="text-sm text-red-600 font-medium">Hauptabweichungen</p>
|
|
<p className="text-xs text-red-500 mt-1">(MAJOR)</p>
|
|
{findings.open_majors > 0 && (
|
|
<p className="text-xs text-red-700 mt-2 font-medium">
|
|
{findings.open_majors} offen
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
|
|
<p className="text-3xl font-bold text-orange-700">{findings.minor_count}</p>
|
|
<p className="text-sm text-orange-600 font-medium">Nebenabweichungen</p>
|
|
<p className="text-xs text-orange-500 mt-1">(MINOR)</p>
|
|
{findings.open_minors > 0 && (
|
|
<p className="text-xs text-orange-700 mt-2 font-medium">
|
|
{findings.open_minors} offen
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
|
|
<p className="text-3xl font-bold text-blue-700">{findings.ofi_count}</p>
|
|
<p className="text-sm text-blue-600 font-medium">Verbesserungen</p>
|
|
<p className="text-xs text-blue-500 mt-1">(OFI)</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-slate-50 rounded-lg border border-slate-200">
|
|
<p className="text-3xl font-bold text-slate-700">{findings.total}</p>
|
|
<p className="text-sm text-slate-600 font-medium">Gesamt Findings</p>
|
|
</div>
|
|
<div className="text-center p-4 bg-purple-50 rounded-lg border border-purple-200">
|
|
<div className="flex flex-col items-center">
|
|
<svg className={`w-8 h-8 ${findings.open_majors === 0 ? 'text-green-500' : 'text-red-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
{findings.open_majors === 0 ? (
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
) : (
|
|
<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>
|
|
<p className="text-sm text-purple-600 font-medium mt-2">Zertifizierung</p>
|
|
<p className={`text-xs mt-1 font-medium ${findings.open_majors === 0 ? 'text-green-600' : 'text-red-600'}`}>
|
|
{findings.open_majors === 0 ? 'Moeglich' : 'Blockiert'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 p-3 bg-slate-50 rounded-lg">
|
|
<p className="text-xs text-slate-600">
|
|
<strong>Hauptabweichung (MAJOR):</strong> Signifikante Abweichung von Anforderungen - blockiert Zertifizierung bis zur Behebung.{' '}
|
|
<strong>Nebenabweichung (MINOR):</strong> Kleinere Abweichung - erfordert CAPA (Corrective Action) innerhalb 90 Tagen.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{/* Sessions Sidebar */}
|
|
<div className="lg:col-span-1 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-slate-900">Audit Sessions</h2>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="p-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex justify-center py-8">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
</div>
|
|
) : sessions.length === 0 ? (
|
|
<div className="text-center py-8 text-slate-500">
|
|
<p>Keine Sessions vorhanden</p>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="mt-2 text-purple-600 hover:text-purple-700"
|
|
>
|
|
Erste Session erstellen
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{sessions.map((session) => (
|
|
<div
|
|
key={session.id}
|
|
onClick={() => setSelectedSession(session)}
|
|
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
|
|
selectedSession?.id === session.id
|
|
? 'border-purple-500 bg-purple-50'
|
|
: 'border-slate-200 hover:border-slate-300'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
|
session.status === 'completed' ? 'bg-green-100 text-green-700' :
|
|
session.status === 'in_progress' ? 'bg-blue-100 text-blue-700' :
|
|
session.status === 'archived' ? 'bg-slate-100 text-slate-700' :
|
|
'bg-yellow-100 text-yellow-700'
|
|
}`}>
|
|
{session.status === 'completed' ? 'Abgeschlossen' :
|
|
session.status === 'in_progress' ? 'In Bearbeitung' :
|
|
session.status === 'archived' ? 'Archiviert' : 'Entwurf'}
|
|
</span>
|
|
<span className="text-xs text-slate-500">{session.completion_percentage.toFixed(0)}%</span>
|
|
</div>
|
|
<h3 className="font-medium text-slate-900 truncate">{session.name}</h3>
|
|
<p className="text-sm text-slate-500">{session.auditor_name}</p>
|
|
<div className="mt-2 h-1.5 bg-slate-200 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-purple-500"
|
|
style={{ width: `${session.completion_percentage}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Checklist Content */}
|
|
<div className="lg:col-span-3 space-y-4">
|
|
{!selectedSession ? (
|
|
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
|
|
<svg className="w-16 h-16 mx-auto text-slate-300 mb-4" 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 2" />
|
|
</svg>
|
|
<h3 className="text-lg font-medium text-slate-900">Waehlen Sie eine Session</h3>
|
|
<p className="text-slate-500 mt-2">Waehlen Sie eine Audit-Session aus der Liste oder erstellen Sie eine neue.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Session Header */}
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-slate-900">{selectedSession.name}</h2>
|
|
<p className="text-slate-500">{selectedSession.auditor_name} {selectedSession.auditor_organization && `- ${selectedSession.auditor_organization}`}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{selectedSession.status === 'draft' && (
|
|
<button
|
|
onClick={() => startSession(selectedSession.id)}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
Starten
|
|
</button>
|
|
)}
|
|
{selectedSession.status === 'in_progress' && (
|
|
<button
|
|
onClick={() => completeSession(selectedSession.id)}
|
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
|
>
|
|
Abschliessen
|
|
</button>
|
|
)}
|
|
{selectedSession.status === 'completed' && (
|
|
<button
|
|
onClick={() => downloadPdf(selectedSession.id)}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
|
>
|
|
PDF Export
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Statistics */}
|
|
{statistics && (
|
|
<div className="grid grid-cols-6 gap-4">
|
|
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-slate-900">{statistics.total}</p>
|
|
<p className="text-xs text-slate-500">Gesamt</p>
|
|
</div>
|
|
<div className="text-center p-3 bg-green-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-green-700">{statistics.compliant + statistics.compliant_with_notes}</p>
|
|
<p className="text-xs text-green-600">Konform</p>
|
|
</div>
|
|
<div className="text-center p-3 bg-red-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-red-700">{statistics.non_compliant}</p>
|
|
<p className="text-xs text-red-600">Nicht konform</p>
|
|
</div>
|
|
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-slate-700">{statistics.not_applicable}</p>
|
|
<p className="text-xs text-slate-500">N/A</p>
|
|
</div>
|
|
<div className="text-center p-3 bg-yellow-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-yellow-700">{statistics.pending}</p>
|
|
<p className="text-xs text-yellow-600">Ausstehend</p>
|
|
</div>
|
|
<div className="text-center p-3 bg-purple-50 rounded-lg">
|
|
<p className="text-2xl font-bold text-purple-700">{statistics.completion_percentage.toFixed(0)}%</p>
|
|
<p className="text-xs text-purple-600">Fortschritt</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex gap-4">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
|
placeholder="Suche..."
|
|
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
|
/>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => { setStatusFilter(e.target.value); setPage(1) }}
|
|
className="px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
|
>
|
|
<option value="">Alle Status</option>
|
|
<option value="pending">Ausstehend</option>
|
|
<option value="compliant">Konform</option>
|
|
<option value="non_compliant">Nicht konform</option>
|
|
<option value="not_applicable">N/A</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Checklist Table */}
|
|
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
|
{checklistLoading ? (
|
|
<div className="flex justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
</div>
|
|
) : checklist.length === 0 ? (
|
|
<div className="text-center py-12 text-slate-500">
|
|
Keine Eintraege gefunden
|
|
</div>
|
|
) : (
|
|
<table className="w-full">
|
|
<thead className="bg-slate-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Regulation</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Artikel</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Controls</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Aktion</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-200">
|
|
{checklist.map((item) => {
|
|
const resultConfig = RESULT_COLORS[item.current_result] || RESULT_COLORS.pending
|
|
return (
|
|
<tr key={item.requirement_id} className="hover:bg-slate-50">
|
|
<td className="px-4 py-3">
|
|
<span className="font-mono text-sm text-purple-600">{item.regulation_code}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="font-medium">{item.article}</span>
|
|
{item.paragraph && <span className="text-slate-500 text-sm"> {item.paragraph}</span>}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<p className="text-sm text-slate-900 line-clamp-2">{item.title}</p>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
item.controls_mapped > 0 ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'
|
|
}`}>
|
|
{item.controls_mapped}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<span className={`px-2 py-1 text-xs rounded-full ${resultConfig.bg} ${resultConfig.text}`}>
|
|
{resultConfig.label}
|
|
</span>
|
|
{item.is_signed && (
|
|
<svg className="w-4 h-4 text-green-600 inline-block ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-center">
|
|
<button
|
|
onClick={() => { setSelectedItem(item); setShowSignOffModal(true) }}
|
|
className="px-3 py-1 text-sm bg-purple-100 text-purple-700 rounded hover:bg-purple-200"
|
|
disabled={selectedSession.status !== 'in_progress' && selectedSession.status !== 'draft'}
|
|
>
|
|
Bewerten
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="px-4 py-3 border-t flex items-center justify-between">
|
|
<button
|
|
onClick={() => setPage(Math.max(1, page - 1))}
|
|
disabled={page === 1}
|
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
|
>
|
|
Zurueck
|
|
</button>
|
|
<span className="text-sm text-slate-500">
|
|
Seite {page} von {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
|
disabled={page === totalPages}
|
|
className="px-3 py-1 border rounded disabled:opacity-50"
|
|
>
|
|
Weiter
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create Session Modal */}
|
|
{showCreateModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl p-6 w-full max-w-md">
|
|
<h3 className="text-lg font-semibold mb-4">Neue Audit Session</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
|
<input
|
|
type="text"
|
|
value={newSession.name}
|
|
onChange={(e) => setNewSession({ ...newSession, name: e.target.value })}
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
placeholder="z.B. Q1 2026 DSGVO Audit"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Auditor Name</label>
|
|
<input
|
|
type="text"
|
|
value={newSession.auditor_name}
|
|
onChange={(e) => setNewSession({ ...newSession, auditor_name: e.target.value })}
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
placeholder="Dr. Max Mustermann"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Organisation (optional)</label>
|
|
<input
|
|
type="text"
|
|
value={newSession.auditor_organization}
|
|
onChange={(e) => setNewSession({ ...newSession, auditor_organization: e.target.value })}
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
placeholder="TÜV Rheinland"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3 mt-6">
|
|
<button
|
|
onClick={() => setShowCreateModal(false)}
|
|
className="px-4 py-2 border rounded-lg"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={createSession}
|
|
disabled={!newSession.name || !newSession.auditor_name}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
Erstellen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sign Off Modal */}
|
|
{showSignOffModal && selectedItem && (
|
|
<SignOffModal
|
|
item={selectedItem}
|
|
onClose={() => { setShowSignOffModal(false); setSelectedItem(null) }}
|
|
onSignOff={signOffItem}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Sign Off Modal Component
|
|
function SignOffModal({
|
|
item,
|
|
onClose,
|
|
onSignOff,
|
|
}: {
|
|
item: ChecklistItem
|
|
onClose: () => void
|
|
onSignOff: (result: string, notes: string, sign: boolean) => void
|
|
}) {
|
|
const [result, setResult] = useState(item.current_result === 'pending' ? '' : item.current_result)
|
|
const [notes, setNotes] = useState(item.notes || '')
|
|
const [sign, setSign] = useState(false)
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl p-6 w-full max-w-lg">
|
|
<h3 className="text-lg font-semibold mb-2">Anforderung bewerten</h3>
|
|
<p className="text-sm text-slate-500 mb-4">
|
|
{item.regulation_code} {item.article}: {item.title}
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-2">Bewertung</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{[
|
|
{ value: 'compliant', label: 'Konform', color: 'green' },
|
|
{ value: 'compliant_notes', label: 'Konform (mit Anm.)', color: 'green' },
|
|
{ value: 'non_compliant', label: 'Nicht konform', color: 'red' },
|
|
{ value: 'not_applicable', label: 'Nicht anwendbar', color: 'slate' },
|
|
].map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setResult(opt.value)}
|
|
className={`p-3 rounded-lg border-2 text-left transition-colors ${
|
|
result === opt.value
|
|
? opt.color === 'green' ? 'border-green-500 bg-green-50' :
|
|
opt.color === 'red' ? 'border-red-500 bg-red-50' :
|
|
'border-slate-500 bg-slate-50'
|
|
: 'border-slate-200 hover:border-slate-300'
|
|
}`}
|
|
>
|
|
<span className="font-medium">{opt.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700 mb-1">Anmerkungen</label>
|
|
<textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg"
|
|
rows={3}
|
|
placeholder="Optionale Anmerkungen..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="sign"
|
|
checked={sign}
|
|
onChange={(e) => setSign(e.target.checked)}
|
|
className="w-4 h-4 rounded"
|
|
/>
|
|
<label htmlFor="sign" className="text-sm text-slate-700">
|
|
Digitale Signatur erstellen (SHA-256)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 mt-6">
|
|
<button onClick={onClose} className="px-4 py-2 border rounded-lg">
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={() => onSignOff(result, notes, sign)}
|
|
disabled={!result}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
Speichern
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|