Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
868 lines
32 KiB
TypeScript
868 lines
32 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* Audit Checklist Page - Sprint 3 Feature
|
||
*
|
||
* Ermoeglicht Auditoren:
|
||
* - Audit-Sessions zu erstellen und zu verwalten
|
||
* - Checklisten-Items durchzuarbeiten
|
||
* - Sign-offs mit digitaler Signatur (SHA-256) zu erstellen
|
||
* - Fortschritt und Statistiken zu verfolgen
|
||
*/
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import Link from 'next/link'
|
||
import AdminLayout from '@/components/admin/AdminLayout'
|
||
import { getTerm, Language } from '@/lib/compliance-i18n'
|
||
|
||
// Types based on backend schemas
|
||
interface AuditSession {
|
||
id: string
|
||
name: string
|
||
description: string | null
|
||
auditor_name: string
|
||
auditor_email: string | null
|
||
status: 'draft' | 'in_progress' | 'completed' | 'archived'
|
||
regulation_ids: string[] | null
|
||
total_items: number
|
||
completed_items: number
|
||
created_at: string
|
||
started_at: string | null
|
||
completed_at: string | null
|
||
}
|
||
|
||
interface AuditChecklistItem {
|
||
requirement_id: string
|
||
regulation_code: string
|
||
article: string
|
||
title: string
|
||
current_result: 'compliant' | 'compliant_notes' | 'non_compliant' | 'not_applicable' | 'pending'
|
||
notes: string | null
|
||
is_signed: boolean
|
||
signed_at: string | null
|
||
signed_by: string | null
|
||
evidence_count: number
|
||
controls_mapped: number
|
||
}
|
||
|
||
interface AuditStatistics {
|
||
total: number
|
||
compliant: number
|
||
compliant_with_notes: number
|
||
non_compliant: number
|
||
not_applicable: number
|
||
pending: number
|
||
completion_percentage: number
|
||
}
|
||
|
||
interface Regulation {
|
||
id: string
|
||
code: string
|
||
name: string
|
||
requirement_count: number
|
||
}
|
||
|
||
// Result status configuration
|
||
const RESULT_STATUS = {
|
||
pending: { label: 'Ausstehend', labelEn: 'Pending', color: 'bg-slate-100 text-slate-700', icon: '○' },
|
||
compliant: { label: 'Konform', labelEn: 'Compliant', color: 'bg-green-100 text-green-700', icon: '✓' },
|
||
compliant_notes: { label: 'Konform (Anm.)', labelEn: 'Compliant w/ Notes', color: 'bg-yellow-100 text-yellow-700', icon: '⚠' },
|
||
non_compliant: { label: 'Nicht konform', labelEn: 'Non-Compliant', color: 'bg-red-100 text-red-700', icon: '✗' },
|
||
not_applicable: { label: 'N/A', labelEn: 'N/A', color: 'bg-slate-50 text-slate-500', icon: '−' },
|
||
}
|
||
|
||
const SESSION_STATUS = {
|
||
draft: { label: 'Entwurf', color: 'bg-slate-100 text-slate-700' },
|
||
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
|
||
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
|
||
archived: { label: 'Archiviert', color: 'bg-slate-50 text-slate-500' },
|
||
}
|
||
|
||
export default function AuditChecklistPage() {
|
||
const [sessions, setSessions] = useState<AuditSession[]>([])
|
||
const [selectedSession, setSelectedSession] = useState<AuditSession | null>(null)
|
||
const [checklistItems, setChecklistItems] = useState<AuditChecklistItem[]>([])
|
||
const [statistics, setStatistics] = useState<AuditStatistics | null>(null)
|
||
const [regulations, setRegulations] = useState<Regulation[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [loadingChecklist, setLoadingChecklist] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
// Filters
|
||
const [filterStatus, setFilterStatus] = useState<string>('all')
|
||
const [filterRegulation, setFilterRegulation] = useState<string>('all')
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
|
||
// Modal states
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [showSignOffModal, setShowSignOffModal] = useState(false)
|
||
const [selectedItem, setSelectedItem] = useState<AuditChecklistItem | null>(null)
|
||
|
||
// Language
|
||
const [lang] = useState<Language>('de')
|
||
|
||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||
|
||
// Load sessions
|
||
const loadSessions = useCallback(async () => {
|
||
try {
|
||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/sessions`)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setSessions(data.sessions || [])
|
||
} else {
|
||
console.error('Failed to load sessions:', res.status)
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load sessions:', err)
|
||
setError('Verbindung zum Backend fehlgeschlagen')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [BACKEND_URL])
|
||
|
||
// Load regulations for filter
|
||
const loadRegulations = useCallback(async () => {
|
||
try {
|
||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/regulations`)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setRegulations(data.regulations || [])
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load regulations:', err)
|
||
}
|
||
}, [BACKEND_URL])
|
||
|
||
// Load checklist for selected session
|
||
const loadChecklist = useCallback(async (sessionId: string) => {
|
||
setLoadingChecklist(true)
|
||
try {
|
||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/checklist/${sessionId}`)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setChecklistItems(data.items || [])
|
||
setStatistics(data.statistics || null)
|
||
// Update session with latest data
|
||
if (data.session) {
|
||
setSelectedSession(data.session)
|
||
}
|
||
} else {
|
||
console.error('Failed to load checklist:', res.status)
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load checklist:', err)
|
||
} finally {
|
||
setLoadingChecklist(false)
|
||
}
|
||
}, [BACKEND_URL])
|
||
|
||
useEffect(() => {
|
||
loadSessions()
|
||
loadRegulations()
|
||
}, [loadSessions, loadRegulations])
|
||
|
||
useEffect(() => {
|
||
if (selectedSession) {
|
||
loadChecklist(selectedSession.id)
|
||
}
|
||
}, [selectedSession, loadChecklist])
|
||
|
||
// Filter checklist items
|
||
const filteredItems = checklistItems.filter(item => {
|
||
if (filterStatus !== 'all' && item.current_result !== filterStatus) return false
|
||
if (filterRegulation !== 'all' && item.regulation_code !== filterRegulation) return false
|
||
if (searchQuery) {
|
||
const query = searchQuery.toLowerCase()
|
||
return (
|
||
item.title.toLowerCase().includes(query) ||
|
||
item.article.toLowerCase().includes(query) ||
|
||
item.regulation_code.toLowerCase().includes(query)
|
||
)
|
||
}
|
||
return true
|
||
})
|
||
|
||
// Handle sign-off
|
||
const handleSignOff = async (
|
||
item: AuditChecklistItem,
|
||
result: AuditChecklistItem['current_result'],
|
||
notes: string,
|
||
sign: boolean
|
||
) => {
|
||
if (!selectedSession) return
|
||
|
||
try {
|
||
const res = await fetch(
|
||
`${BACKEND_URL}/api/v1/compliance/audit/checklist/${selectedSession.id}/items/${item.requirement_id}/sign-off`,
|
||
{
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ result, notes, sign }),
|
||
}
|
||
)
|
||
|
||
if (res.ok) {
|
||
// Reload checklist to get updated data
|
||
await loadChecklist(selectedSession.id)
|
||
setShowSignOffModal(false)
|
||
setSelectedItem(null)
|
||
} else {
|
||
const err = await res.json()
|
||
alert(`Fehler: ${err.detail || 'Sign-off fehlgeschlagen'}`)
|
||
}
|
||
} catch (err) {
|
||
console.error('Sign-off failed:', err)
|
||
alert('Netzwerkfehler bei Sign-off')
|
||
}
|
||
}
|
||
|
||
// Session list view when no session is selected
|
||
if (!selectedSession && !loading) {
|
||
return (
|
||
<AdminLayout title="Audit Checkliste" description="Strukturierte Pruefungen durchfuehren">
|
||
<div className="mb-6 flex items-center justify-between">
|
||
<Link
|
||
href="/admin/compliance"
|
||
className="text-sm text-slate-600 hover:text-slate-900 flex items-center gap-1"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||
</svg>
|
||
Zurueck zu Compliance
|
||
</Link>
|
||
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
|
||
>
|
||
<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>
|
||
Neue Audit-Session
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Session Cards */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{sessions.length === 0 ? (
|
||
<div className="col-span-full bg-white rounded-lg border border-slate-200 p-8 text-center">
|
||
<svg className="w-12 h-12 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 2m-6 9l2 2 4-4" />
|
||
</svg>
|
||
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Audit-Sessions</h3>
|
||
<p className="text-slate-500 mb-4">Erstellen Sie eine neue Audit-Session um zu beginnen.</p>
|
||
<button
|
||
onClick={() => setShowCreateModal(true)}
|
||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||
>
|
||
Erste Session erstellen
|
||
</button>
|
||
</div>
|
||
) : (
|
||
sessions.map(session => (
|
||
<SessionCard
|
||
key={session.id}
|
||
session={session}
|
||
onClick={() => setSelectedSession(session)}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Create Session Modal */}
|
||
{showCreateModal && (
|
||
<CreateSessionModal
|
||
regulations={regulations}
|
||
onClose={() => setShowCreateModal(false)}
|
||
onCreate={async (data) => {
|
||
try {
|
||
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/sessions`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data),
|
||
})
|
||
if (res.ok) {
|
||
await loadSessions()
|
||
setShowCreateModal(false)
|
||
} else {
|
||
const err = await res.json()
|
||
alert(`Fehler: ${err.detail || 'Session konnte nicht erstellt werden'}`)
|
||
}
|
||
} catch (err) {
|
||
console.error('Create session failed:', err)
|
||
alert('Netzwerkfehler')
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
</AdminLayout>
|
||
)
|
||
}
|
||
|
||
// Loading state
|
||
if (loading) {
|
||
return (
|
||
<AdminLayout title="Audit Checkliste" description="Laden...">
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||
</div>
|
||
</AdminLayout>
|
||
)
|
||
}
|
||
|
||
// Checklist view for selected session
|
||
return (
|
||
<AdminLayout title={selectedSession?.name || 'Audit Checkliste'} description="Pruefung durchfuehren">
|
||
{/* Header */}
|
||
<div className="mb-6 flex items-center justify-between">
|
||
<button
|
||
onClick={() => setSelectedSession(null)}
|
||
className="text-sm text-slate-600 hover:text-slate-900 flex items-center gap-1"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||
</svg>
|
||
Zurueck zur Uebersicht
|
||
</button>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<span className={`px-3 py-1 text-sm font-medium rounded-full ${SESSION_STATUS[selectedSession?.status || 'draft'].color}`}>
|
||
{SESSION_STATUS[selectedSession?.status || 'draft'].label}
|
||
</span>
|
||
<button
|
||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 flex items-center gap-2"
|
||
onClick={() => {/* Export PDF */}}
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||
Export PDF
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Progress Bar */}
|
||
{statistics && (
|
||
<AuditProgressBar statistics={statistics} lang={lang} />
|
||
)}
|
||
|
||
{/* Filters */}
|
||
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6">
|
||
<div className="flex flex-wrap gap-4">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<label className="block text-xs font-medium text-slate-500 mb-1">Suche</label>
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
placeholder="Artikel, Titel..."
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-500 mb-1">Verordnung</label>
|
||
<select
|
||
value={filterRegulation}
|
||
onChange={(e) => setFilterRegulation(e.target.value)}
|
||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
>
|
||
<option value="all">Alle</option>
|
||
{regulations.map(reg => (
|
||
<option key={reg.code} value={reg.code}>{reg.code}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-medium text-slate-500 mb-1">Status</label>
|
||
<select
|
||
value={filterStatus}
|
||
onChange={(e) => setFilterStatus(e.target.value)}
|
||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||
>
|
||
<option value="all">Alle</option>
|
||
{Object.entries(RESULT_STATUS).map(([key, { label }]) => (
|
||
<option key={key} value={key}>{label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Checklist Items */}
|
||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||
{loadingChecklist ? (
|
||
<div className="p-8 text-center">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||
<p className="mt-2 text-slate-500">Checkliste wird geladen...</p>
|
||
</div>
|
||
) : filteredItems.length === 0 ? (
|
||
<div className="p-8 text-center">
|
||
<p className="text-slate-500">Keine Eintraege gefunden</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-slate-100">
|
||
{filteredItems.map((item, index) => (
|
||
<ChecklistItemRow
|
||
key={item.requirement_id}
|
||
item={item}
|
||
index={index + 1}
|
||
onSignOff={() => {
|
||
setSelectedItem(item)
|
||
setShowSignOffModal(true)
|
||
}}
|
||
lang={lang}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Sign-off Modal */}
|
||
{showSignOffModal && selectedItem && (
|
||
<SignOffModal
|
||
item={selectedItem}
|
||
auditorName={selectedSession?.auditor_name || ''}
|
||
onClose={() => {
|
||
setShowSignOffModal(false)
|
||
setSelectedItem(null)
|
||
}}
|
||
onSubmit={(result, notes, sign) => handleSignOff(selectedItem, result, notes, sign)}
|
||
/>
|
||
)}
|
||
</AdminLayout>
|
||
)
|
||
}
|
||
|
||
// Session Card Component
|
||
function SessionCard({ session, onClick }: { session: AuditSession; onClick: () => void }) {
|
||
const completionPercent = session.total_items > 0
|
||
? Math.round((session.completed_items / session.total_items) * 100)
|
||
: 0
|
||
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className="bg-white rounded-lg border border-slate-200 p-4 text-left hover:shadow-md hover:border-primary-300 transition-all"
|
||
>
|
||
<div className="flex items-start justify-between mb-3">
|
||
<h3 className="font-semibold text-slate-900">{session.name}</h3>
|
||
<span className={`px-2 py-0.5 text-xs font-medium rounded ${SESSION_STATUS[session.status].color}`}>
|
||
{SESSION_STATUS[session.status].label}
|
||
</span>
|
||
</div>
|
||
|
||
{session.description && (
|
||
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{session.description}</p>
|
||
)}
|
||
|
||
<div className="mb-3">
|
||
<div className="flex justify-between text-xs text-slate-500 mb-1">
|
||
<span>{session.completed_items} / {session.total_items} Punkte</span>
|
||
<span>{completionPercent}%</span>
|
||
</div>
|
||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-primary-500 transition-all"
|
||
style={{ width: `${completionPercent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between text-xs text-slate-500">
|
||
<span>Auditor: {session.auditor_name}</span>
|
||
<span>{new Date(session.created_at).toLocaleDateString('de-DE')}</span>
|
||
</div>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// Progress Bar Component
|
||
function AuditProgressBar({ statistics, lang }: { statistics: AuditStatistics; lang: Language }) {
|
||
const segments = [
|
||
{ key: 'compliant', count: statistics.compliant, color: 'bg-green-500', label: 'Konform' },
|
||
{ key: 'compliant_with_notes', count: statistics.compliant_with_notes, color: 'bg-yellow-500', label: 'Mit Anm.' },
|
||
{ key: 'non_compliant', count: statistics.non_compliant, color: 'bg-red-500', label: 'Nicht konform' },
|
||
{ key: 'not_applicable', count: statistics.not_applicable, color: 'bg-slate-300', label: 'N/A' },
|
||
{ key: 'pending', count: statistics.pending, color: 'bg-slate-100', label: 'Offen' },
|
||
]
|
||
|
||
return (
|
||
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="font-semibold text-slate-900">Fortschritt</h3>
|
||
<span className="text-2xl font-bold text-primary-600">
|
||
{Math.round(statistics.completion_percentage)}%
|
||
</span>
|
||
</div>
|
||
|
||
{/* Stacked Progress Bar */}
|
||
<div className="h-4 bg-slate-100 rounded-full overflow-hidden flex">
|
||
{segments.map(seg => (
|
||
seg.count > 0 && (
|
||
<div
|
||
key={seg.key}
|
||
className={`${seg.color} transition-all`}
|
||
style={{ width: `${(seg.count / statistics.total) * 100}%` }}
|
||
title={`${seg.label}: ${seg.count}`}
|
||
/>
|
||
)
|
||
))}
|
||
</div>
|
||
|
||
{/* Legend */}
|
||
<div className="flex flex-wrap gap-4 mt-3">
|
||
{segments.map(seg => (
|
||
<div key={seg.key} className="flex items-center gap-1.5 text-sm">
|
||
<span className={`w-3 h-3 rounded ${seg.color}`} />
|
||
<span className="text-slate-600">{seg.label}:</span>
|
||
<span className="font-medium text-slate-900">{seg.count}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Checklist Item Row Component
|
||
function ChecklistItemRow({
|
||
item,
|
||
index,
|
||
onSignOff,
|
||
lang,
|
||
}: {
|
||
item: AuditChecklistItem
|
||
index: number
|
||
onSignOff: () => void
|
||
lang: Language
|
||
}) {
|
||
const status = RESULT_STATUS[item.current_result]
|
||
|
||
return (
|
||
<div className="p-4 hover:bg-slate-50 transition-colors">
|
||
<div className="flex items-start gap-4">
|
||
{/* Status Icon */}
|
||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${status.color}`}>
|
||
{status.icon}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="text-xs font-medium text-slate-500">{index}.</span>
|
||
<span className="font-mono text-sm text-primary-600">{item.regulation_code}</span>
|
||
<span className="font-mono text-sm text-slate-700">{item.article}</span>
|
||
{item.is_signed && (
|
||
<span className="px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded flex items-center gap-1">
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||
</svg>
|
||
Signiert
|
||
</span>
|
||
)}
|
||
</div>
|
||
<h4 className="text-sm font-medium text-slate-900 mb-1">{item.title}</h4>
|
||
{item.notes && (
|
||
<p className="text-xs text-slate-500 italic mb-2">"{item.notes}"</p>
|
||
)}
|
||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||
<span>{item.controls_mapped} Controls</span>
|
||
<span>{item.evidence_count} Nachweise</span>
|
||
{item.signed_at && (
|
||
<span>Signiert: {new Date(item.signed_at).toLocaleDateString('de-DE')}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex items-center gap-2">
|
||
<span className={`px-2 py-1 text-xs font-medium rounded ${status.color}`}>
|
||
{lang === 'de' ? status.label : status.labelEn}
|
||
</span>
|
||
<button
|
||
onClick={onSignOff}
|
||
className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
|
||
title="Sign-off"
|
||
>
|
||
<svg className="w-5 h-5" 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>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Create Session Modal
|
||
function CreateSessionModal({
|
||
regulations,
|
||
onClose,
|
||
onCreate,
|
||
}: {
|
||
regulations: Regulation[]
|
||
onClose: () => void
|
||
onCreate: (data: { name: string; description?: string; auditor_name: string; auditor_email?: string; regulation_codes?: string[] }) => void
|
||
}) {
|
||
const [name, setName] = useState('')
|
||
const [description, setDescription] = useState('')
|
||
const [auditorName, setAuditorName] = useState('')
|
||
const [auditorEmail, setAuditorEmail] = useState('')
|
||
const [selectedRegs, setSelectedRegs] = useState<string[]>([])
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
if (!name || !auditorName) return
|
||
|
||
setSubmitting(true)
|
||
await onCreate({
|
||
name,
|
||
description: description || undefined,
|
||
auditor_name: auditorName,
|
||
auditor_email: auditorEmail || undefined,
|
||
regulation_codes: selectedRegs.length > 0 ? selectedRegs : undefined,
|
||
})
|
||
setSubmitting(false)
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||
<h2 className="text-lg font-semibold text-slate-900">Neue Audit-Session</h2>
|
||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||
Name der Pruefung *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="z.B. Q1 2026 Compliance Audit"
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||
Beschreibung
|
||
</label>
|
||
<textarea
|
||
value={description}
|
||
onChange={(e) => setDescription(e.target.value)}
|
||
placeholder="Optionale Beschreibung..."
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
|
||
rows={2}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||
Auditor Name *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={auditorName}
|
||
onChange={(e) => setAuditorName(e.target.value)}
|
||
placeholder="Dr. Max Mustermann"
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||
E-Mail
|
||
</label>
|
||
<input
|
||
type="email"
|
||
value={auditorEmail}
|
||
onChange={(e) => setAuditorEmail(e.target.value)}
|
||
placeholder="auditor@example.com"
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||
Verordnungen (optional - leer = alle)
|
||
</label>
|
||
<div className="flex flex-wrap gap-2 p-2 border border-slate-300 rounded-lg max-h-32 overflow-y-auto">
|
||
{regulations.map(reg => (
|
||
<label key={reg.code} className="flex items-center gap-1.5">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedRegs.includes(reg.code)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedRegs([...selectedRegs, reg.code])
|
||
} else {
|
||
setSelectedRegs(selectedRegs.filter(r => r !== reg.code))
|
||
}
|
||
}}
|
||
className="rounded"
|
||
/>
|
||
<span className="text-sm text-slate-700">{reg.code}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-slate-600 hover:text-slate-800"
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={!name || !auditorName || submitting}
|
||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||
>
|
||
{submitting ? 'Erstelle...' : 'Session erstellen'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Sign-off Modal
|
||
function SignOffModal({
|
||
item,
|
||
auditorName,
|
||
onClose,
|
||
onSubmit,
|
||
}: {
|
||
item: AuditChecklistItem
|
||
auditorName: string
|
||
onClose: () => void
|
||
onSubmit: (result: AuditChecklistItem['current_result'], notes: string, sign: boolean) => void
|
||
}) {
|
||
const [result, setResult] = useState<AuditChecklistItem['current_result']>(item.current_result)
|
||
const [notes, setNotes] = useState(item.notes || '')
|
||
const [sign, setSign] = useState(false)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setSubmitting(true)
|
||
await onSubmit(result, notes, sign)
|
||
setSubmitting(false)
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-slate-200">
|
||
<h2 className="text-lg font-semibold text-slate-900">Auditor Sign-off</h2>
|
||
<p className="text-sm text-slate-500 mt-1">
|
||
{item.regulation_code} {item.article} - {item.title}
|
||
</p>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||
Pruefungsergebnis
|
||
</label>
|
||
<div className="space-y-2">
|
||
{Object.entries(RESULT_STATUS).map(([key, { label, color }]) => (
|
||
<label key={key} className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-50 cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
name="result"
|
||
value={key}
|
||
checked={result === key}
|
||
onChange={() => setResult(key as typeof result)}
|
||
className="w-4 h-4"
|
||
/>
|
||
<span className={`px-2 py-0.5 text-sm font-medium rounded ${color}`}>
|
||
{label}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</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)}
|
||
placeholder="Optionale Anmerkungen zur Pruefung..."
|
||
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
|
||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||
<label className="flex items-start gap-3 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={sign}
|
||
onChange={(e) => setSign(e.target.checked)}
|
||
className="mt-1 rounded"
|
||
/>
|
||
<div>
|
||
<span className="font-medium text-amber-800">Digital signieren</span>
|
||
<p className="text-sm text-amber-700 mt-0.5">
|
||
Erstellt eine SHA-256 Signatur des Ergebnisses. Diese Aktion kann nicht rueckgaengig gemacht werden.
|
||
</p>
|
||
</div>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="text-sm text-slate-500 pt-2 border-t border-slate-100">
|
||
<p>Auditor: <span className="font-medium text-slate-700">{auditorName}</span></p>
|
||
<p>Datum: <span className="font-medium text-slate-700">{new Date().toLocaleString('de-DE')}</span></p>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-3 pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="px-4 py-2 text-slate-600 hover:text-slate-800"
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={submitting}
|
||
className={`px-4 py-2 rounded-lg disabled:opacity-50 ${
|
||
sign
|
||
? 'bg-amber-600 text-white hover:bg-amber-700'
|
||
: 'bg-primary-600 text-white hover:bg-primary-700'
|
||
}`}
|
||
>
|
||
{submitting ? 'Speichere...' : sign ? 'Signieren & Speichern' : 'Speichern'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|