Initial commit: breakpilot-lehrer - Lehrer KI Platform
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>
This commit is contained in:
867
website/app/admin/compliance/audit-checklist/page.tsx
Normal file
867
website/app/admin/compliance/audit-checklist/page.tsx
Normal file
@@ -0,0 +1,867 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user