refactor(admin): split audit-checklist, cookie-banner, escalations pages
Each page.tsx was 750-780 LOC. Extracted React components to _components/ and custom hooks to _hooks/ next to each page.tsx. All three pages are now under 215 LOC (well within the 500 LOC hard cap). Zero behavior changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { DisplayChecklistItem, DisplayStatus } from './types'
|
||||
|
||||
interface ChecklistItemCardProps {
|
||||
item: DisplayChecklistItem
|
||||
onStatusChange: (status: DisplayStatus) => void
|
||||
onNotesChange: (notes: string) => void
|
||||
onAddEvidence: () => void
|
||||
}
|
||||
|
||||
export function ChecklistItemCard({
|
||||
item,
|
||||
onStatusChange,
|
||||
onNotesChange,
|
||||
onAddEvidence,
|
||||
}: ChecklistItemCardProps) {
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
|
||||
const statusColors = {
|
||||
compliant: 'bg-green-100 text-green-700 border-green-300',
|
||||
'non-compliant': 'bg-red-100 text-red-700 border-red-300',
|
||||
partial: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
'not-reviewed': 'bg-gray-100 text-gray-500 border-gray-300',
|
||||
}
|
||||
|
||||
const priorityColors = {
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
low: 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
item.status === 'non-compliant' ? 'border-red-200' :
|
||||
item.status === 'partial' ? 'border-yellow-200' :
|
||||
item.status === 'compliant' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-500">{item.category}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[item.priority]}`}>
|
||||
{item.priority === 'critical' ? 'Kritisch' :
|
||||
item.priority === 'high' ? 'Hoch' :
|
||||
item.priority === 'medium' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{item.requirementId}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-900 font-medium">{item.question}</p>
|
||||
</div>
|
||||
<select
|
||||
value={item.status}
|
||||
onChange={(e) => onStatusChange(e.target.value as DisplayStatus)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm font-medium ${statusColors[item.status]}`}
|
||||
>
|
||||
<option value="not-reviewed">Nicht geprueft</option>
|
||||
<option value="compliant">Konform</option>
|
||||
<option value="partial">Teilweise</option>
|
||||
<option value="non-compliant">Nicht konform</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{item.notes && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.evidence.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Nachweise:</span>
|
||||
{item.evidence.map(ev => (
|
||||
<span key={ev} className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.verifiedBy && item.verifiedAt && (
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
Geprueft von {item.verifiedBy} am {item.verifiedAt.toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
{showNotes ? 'Notizen ausblenden' : 'Notizen bearbeiten'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddEvidence}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Nachweis hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNotes && (
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
value={item.notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
placeholder="Notizen hinzufuegen..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
export 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-center gap-2 mb-3">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded" />
|
||||
<div className="h-4 w-16 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
<div className="h-5 w-full bg-gray-200 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PastSession } from './types'
|
||||
|
||||
interface SessionHistoryProps {
|
||||
pastSessions: PastSession[]
|
||||
activeSessionId: string | null
|
||||
}
|
||||
|
||||
export function SessionHistory({ pastSessions, activeSessionId }: SessionHistoryProps) {
|
||||
const router = useRouter()
|
||||
|
||||
if (pastSessions.length === 0) return null
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
draft: 'bg-slate-100 text-slate-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
const statusLabel: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Vergangene Audit-Sessions</h3>
|
||||
<div className="space-y-3">
|
||||
{pastSessions
|
||||
.filter(s => s.id !== activeSessionId)
|
||||
.map(session => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/sdk/audit-report/${session.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusBadge[session.status] || ''}`}>
|
||||
{statusLabel[session.status] || session.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{session.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(session.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{session.completed_items}/{session.total_items} Punkte
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
session.completion_percentage >= 80 ? 'text-green-600' :
|
||||
session.completion_percentage >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{session.completion_percentage}%
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ChecklistTemplate } from './types'
|
||||
|
||||
// =============================================================================
|
||||
// FALLBACK TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
export const checklistTemplates: ChecklistTemplate[] = [
|
||||
{
|
||||
id: 'chk-vvt-001',
|
||||
requirementId: 'req-gdpr-30',
|
||||
question: 'Ist ein Verzeichnis von Verarbeitungstaetigkeiten (VVT) vorhanden und aktuell?',
|
||||
category: 'Dokumentation',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dse-001',
|
||||
requirementId: 'req-gdpr-13',
|
||||
question: 'Sind Datenschutzhinweise fuer alle Verarbeitungen verfuegbar?',
|
||||
category: 'Transparenz',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-consent-001',
|
||||
requirementId: 'req-gdpr-6',
|
||||
question: 'Werden Einwilligungen ordnungsgemaess eingeholt und dokumentiert?',
|
||||
category: 'Einwilligung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsr-001',
|
||||
requirementId: 'req-gdpr-15',
|
||||
question: 'Ist ein Prozess fuer Betroffenenrechte implementiert?',
|
||||
category: 'Betroffenenrechte',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-avv-001',
|
||||
requirementId: 'req-gdpr-28',
|
||||
question: 'Sind Auftragsverarbeitungsvertraege (AVV) mit allen Dienstleistern abgeschlossen?',
|
||||
category: 'Auftragsverarbeitung',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsfa-001',
|
||||
requirementId: 'req-gdpr-35',
|
||||
question: 'Wird eine DSFA fuer Hochrisiko-Verarbeitungen durchgefuehrt?',
|
||||
category: 'Risikobewertung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-tom-001',
|
||||
requirementId: 'req-gdpr-32',
|
||||
question: 'Sind technische und organisatorische Massnahmen dokumentiert?',
|
||||
category: 'TOMs',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-incident-001',
|
||||
requirementId: 'req-gdpr-33',
|
||||
question: 'Gibt es einen Incident-Response-Prozess fuer Datenpannen?',
|
||||
category: 'Incident Response',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-001',
|
||||
requirementId: 'req-ai-act-9',
|
||||
question: 'Ist das KI-System nach EU AI Act klassifiziert?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-002',
|
||||
requirementId: 'req-ai-act-13',
|
||||
question: 'Sind Transparenzanforderungen fuer KI-Systeme erfuellt?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
|
||||
import { DisplayStatus } from './types'
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function mapSDKStatusToDisplay(status: SDKChecklistItem['status']): DisplayStatus {
|
||||
switch (status) {
|
||||
case 'PASSED': return 'compliant'
|
||||
case 'FAILED': return 'non-compliant'
|
||||
case 'NOT_APPLICABLE': return 'partial'
|
||||
case 'PENDING':
|
||||
default: return 'not-reviewed'
|
||||
}
|
||||
}
|
||||
|
||||
export function mapDisplayStatusToSDK(status: DisplayStatus): SDKChecklistItem['status'] {
|
||||
switch (status) {
|
||||
case 'compliant': return 'PASSED'
|
||||
case 'non-compliant': return 'FAILED'
|
||||
case 'partial': return 'NOT_APPLICABLE'
|
||||
case 'not-reviewed':
|
||||
default: return 'PENDING'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type DisplayStatus = 'compliant' | 'non-compliant' | 'partial' | 'not-reviewed'
|
||||
export type DisplayPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
|
||||
export interface DisplayChecklistItem {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
status: DisplayStatus
|
||||
notes: string
|
||||
evidence: string[]
|
||||
priority: DisplayPriority
|
||||
verifiedBy: string | null
|
||||
verifiedAt: Date | null
|
||||
}
|
||||
|
||||
export interface PastSession {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
auditor_name: string
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
completion_percentage: number
|
||||
total_items: number
|
||||
completed_items: number
|
||||
}
|
||||
|
||||
export interface ChecklistTemplate {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
priority: DisplayPriority
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSDK, ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
|
||||
import { PastSession } from '../_components/types'
|
||||
import { checklistTemplates } from '../_components/checklistTemplates'
|
||||
import { mapSDKStatusToDisplay, mapDisplayStatusToSDK } from '../_components/statusHelpers'
|
||||
import { DisplayStatus, DisplayChecklistItem } from '../_components/types'
|
||||
|
||||
export function useAuditChecklist() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
||||
const notesTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
const [pastSessions, setPastSessions] = useState<PastSession[]>([])
|
||||
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
|
||||
const [generatingPdf, setGeneratingPdf] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChecklist = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const sessionsRes = await fetch('/api/sdk/v1/compliance/audit/sessions?status=in_progress')
|
||||
if (sessionsRes.ok) {
|
||||
const sessionsData = await sessionsRes.json()
|
||||
const sessions = sessionsData.sessions || sessionsData
|
||||
if (Array.isArray(sessions) && sessions.length > 0) {
|
||||
const session = sessions[0]
|
||||
setActiveSessionId(session.id)
|
||||
|
||||
const checklistRes = await fetch(`/api/sdk/v1/compliance/audit/checklist/${session.id}`)
|
||||
if (checklistRes.ok) {
|
||||
const checklistData = await checklistRes.json()
|
||||
const items = checklistData.items || checklistData.checklist || checklistData
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
const mapped: SDKChecklistItem[] = items.map((item: Record<string, unknown>) => ({
|
||||
id: (item.id || item.requirement_id || '') as string,
|
||||
requirementId: (item.requirement_id || '') as string,
|
||||
title: (item.title || item.question || '') as string,
|
||||
description: (item.category || item.description || '') as string,
|
||||
status: ((item.status || 'PENDING') as string).toUpperCase() as SDKChecklistItem['status'],
|
||||
notes: (item.notes || item.auditor_notes || '') as string,
|
||||
verifiedBy: (item.verified_by || item.signed_off_by || null) as string | null,
|
||||
verifiedAt: item.verified_at || item.signed_off_at ? new Date((item.verified_at || item.signed_off_at) as string) : null,
|
||||
}))
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: mapped } })
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadFromTemplates()
|
||||
} catch {
|
||||
loadFromTemplates()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromTemplates = () => {
|
||||
if (state.checklist.length > 0) return
|
||||
|
||||
const templatesToLoad = state.requirements.length > 0
|
||||
? checklistTemplates.filter(t =>
|
||||
state.requirements.some(r => r.id === t.requirementId)
|
||||
)
|
||||
: checklistTemplates
|
||||
|
||||
const items: SDKChecklistItem[] = templatesToLoad.map(template => ({
|
||||
id: template.id,
|
||||
requirementId: template.requirementId,
|
||||
title: template.question,
|
||||
description: template.category,
|
||||
status: 'PENDING',
|
||||
notes: '',
|
||||
verifiedBy: null,
|
||||
verifiedAt: null,
|
||||
}))
|
||||
|
||||
if (items.length > 0) {
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: items } })
|
||||
}
|
||||
}
|
||||
|
||||
fetchChecklist()
|
||||
|
||||
const fetchAllSessions = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/audit/sessions')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const sessions = data.sessions || []
|
||||
setPastSessions(sessions)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
fetchAllSessions()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const displayItems: DisplayChecklistItem[] = state.checklist.map(item => {
|
||||
const template = checklistTemplates.find(t => t.id === item.id)
|
||||
return {
|
||||
id: item.id,
|
||||
requirementId: item.requirementId,
|
||||
question: item.title,
|
||||
category: item.description || template?.category || 'Allgemein',
|
||||
status: mapSDKStatusToDisplay(item.status),
|
||||
notes: item.notes,
|
||||
evidence: [],
|
||||
priority: template?.priority || 'medium',
|
||||
verifiedBy: item.verifiedBy,
|
||||
verifiedAt: item.verifiedAt,
|
||||
}
|
||||
})
|
||||
|
||||
const handleStatusChange = async (itemId: string, status: DisplayStatus) => {
|
||||
const sdkStatus = mapDisplayStatusToSDK(status)
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId
|
||||
? {
|
||||
...item,
|
||||
status: sdkStatus,
|
||||
verifiedBy: status !== 'not-reviewed' ? 'Aktueller Benutzer' : null,
|
||||
verifiedAt: status !== 'not-reviewed' ? new Date() : null,
|
||||
}
|
||||
: item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
|
||||
if (activeSessionId) {
|
||||
try {
|
||||
const item = state.checklist.find(i => i.id === itemId)
|
||||
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: sdkStatus === 'PASSED' ? 'compliant' : sdkStatus === 'FAILED' ? 'non_compliant' : sdkStatus === 'NOT_APPLICABLE' ? 'partially_compliant' : 'not_assessed',
|
||||
auditor_notes: item?.notes || '',
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotesChange = useCallback((itemId: string, notes: string) => {
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId ? { ...item, notes } : item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
|
||||
if (notesTimerRef.current[itemId]) {
|
||||
clearTimeout(notesTimerRef.current[itemId])
|
||||
}
|
||||
notesTimerRef.current[itemId] = setTimeout(async () => {
|
||||
if (activeSessionId) {
|
||||
const item = state.checklist.find(i => i.id === itemId)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes }),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [state.checklist, activeSessionId, dispatch])
|
||||
|
||||
const handleExport = () => {
|
||||
const exportData = displayItems.map(item => ({
|
||||
id: item.id,
|
||||
requirementId: item.requirementId,
|
||||
question: item.question,
|
||||
category: item.category,
|
||||
status: item.status,
|
||||
notes: item.notes,
|
||||
priority: item.priority,
|
||||
verifiedBy: item.verifiedBy,
|
||||
verifiedAt: item.verifiedAt?.toISOString() || null,
|
||||
}))
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-checklist-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handlePdfDownload = async () => {
|
||||
if (!activeSessionId) {
|
||||
setError('Kein aktives Audit vorhanden. Erstellen Sie zuerst eine Checkliste.')
|
||||
return
|
||||
}
|
||||
setGeneratingPdf(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${activeSessionId}/report/pdf?language=${pdfLanguage}`)
|
||||
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-checklist-${activeSessionId}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
|
||||
} finally {
|
||||
setGeneratingPdf(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChecklist = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch('/api/sdk/v1/compliance/audit/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `Compliance Audit ${new Date().toLocaleDateString('de-DE')}`,
|
||||
auditor_name: 'Aktueller Benutzer',
|
||||
regulation_codes: ['GDPR'],
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
setError('Fehler beim Erstellen der neuen Checkliste')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
activeSessionId,
|
||||
pastSessions,
|
||||
pdfLanguage,
|
||||
setPdfLanguage,
|
||||
generatingPdf,
|
||||
displayItems,
|
||||
handleStatusChange,
|
||||
handleNotesChange,
|
||||
handleExport,
|
||||
handlePdfDownload,
|
||||
handleNewChecklist,
|
||||
}
|
||||
}
|
||||
@@ -1,408 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK, ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type DisplayStatus = 'compliant' | 'non-compliant' | 'partial' | 'not-reviewed'
|
||||
type DisplayPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
|
||||
interface DisplayChecklistItem {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
status: DisplayStatus
|
||||
notes: string
|
||||
evidence: string[]
|
||||
priority: DisplayPriority
|
||||
verifiedBy: string | null
|
||||
verifiedAt: Date | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function mapSDKStatusToDisplay(status: SDKChecklistItem['status']): DisplayStatus {
|
||||
switch (status) {
|
||||
case 'PASSED': return 'compliant'
|
||||
case 'FAILED': return 'non-compliant'
|
||||
case 'NOT_APPLICABLE': return 'partial'
|
||||
case 'PENDING':
|
||||
default: return 'not-reviewed'
|
||||
}
|
||||
}
|
||||
|
||||
function mapDisplayStatusToSDK(status: DisplayStatus): SDKChecklistItem['status'] {
|
||||
switch (status) {
|
||||
case 'compliant': return 'PASSED'
|
||||
case 'non-compliant': return 'FAILED'
|
||||
case 'partial': return 'NOT_APPLICABLE'
|
||||
case 'not-reviewed':
|
||||
default: return 'PENDING'
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FALLBACK TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
interface ChecklistTemplate {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
priority: DisplayPriority
|
||||
}
|
||||
|
||||
const checklistTemplates: ChecklistTemplate[] = [
|
||||
{
|
||||
id: 'chk-vvt-001',
|
||||
requirementId: 'req-gdpr-30',
|
||||
question: 'Ist ein Verzeichnis von Verarbeitungstaetigkeiten (VVT) vorhanden und aktuell?',
|
||||
category: 'Dokumentation',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dse-001',
|
||||
requirementId: 'req-gdpr-13',
|
||||
question: 'Sind Datenschutzhinweise fuer alle Verarbeitungen verfuegbar?',
|
||||
category: 'Transparenz',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-consent-001',
|
||||
requirementId: 'req-gdpr-6',
|
||||
question: 'Werden Einwilligungen ordnungsgemaess eingeholt und dokumentiert?',
|
||||
category: 'Einwilligung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsr-001',
|
||||
requirementId: 'req-gdpr-15',
|
||||
question: 'Ist ein Prozess fuer Betroffenenrechte implementiert?',
|
||||
category: 'Betroffenenrechte',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-avv-001',
|
||||
requirementId: 'req-gdpr-28',
|
||||
question: 'Sind Auftragsverarbeitungsvertraege (AVV) mit allen Dienstleistern abgeschlossen?',
|
||||
category: 'Auftragsverarbeitung',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsfa-001',
|
||||
requirementId: 'req-gdpr-35',
|
||||
question: 'Wird eine DSFA fuer Hochrisiko-Verarbeitungen durchgefuehrt?',
|
||||
category: 'Risikobewertung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-tom-001',
|
||||
requirementId: 'req-gdpr-32',
|
||||
question: 'Sind technische und organisatorische Massnahmen dokumentiert?',
|
||||
category: 'TOMs',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-incident-001',
|
||||
requirementId: 'req-gdpr-33',
|
||||
question: 'Gibt es einen Incident-Response-Prozess fuer Datenpannen?',
|
||||
category: 'Incident Response',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-001',
|
||||
requirementId: 'req-ai-act-9',
|
||||
question: 'Ist das KI-System nach EU AI Act klassifiziert?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-002',
|
||||
requirementId: 'req-ai-act-13',
|
||||
question: 'Sind Transparenzanforderungen fuer KI-Systeme erfuellt?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function ChecklistItemCard({
|
||||
item,
|
||||
onStatusChange,
|
||||
onNotesChange,
|
||||
onAddEvidence,
|
||||
}: {
|
||||
item: DisplayChecklistItem
|
||||
onStatusChange: (status: DisplayStatus) => void
|
||||
onNotesChange: (notes: string) => void
|
||||
onAddEvidence: () => void
|
||||
}) {
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
|
||||
const statusColors = {
|
||||
compliant: 'bg-green-100 text-green-700 border-green-300',
|
||||
'non-compliant': 'bg-red-100 text-red-700 border-red-300',
|
||||
partial: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
'not-reviewed': 'bg-gray-100 text-gray-500 border-gray-300',
|
||||
}
|
||||
|
||||
const priorityColors = {
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
low: 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
item.status === 'non-compliant' ? 'border-red-200' :
|
||||
item.status === 'partial' ? 'border-yellow-200' :
|
||||
item.status === 'compliant' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-500">{item.category}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[item.priority]}`}>
|
||||
{item.priority === 'critical' ? 'Kritisch' :
|
||||
item.priority === 'high' ? 'Hoch' :
|
||||
item.priority === 'medium' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{item.requirementId}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-900 font-medium">{item.question}</p>
|
||||
</div>
|
||||
<select
|
||||
value={item.status}
|
||||
onChange={(e) => onStatusChange(e.target.value as DisplayStatus)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm font-medium ${statusColors[item.status]}`}
|
||||
>
|
||||
<option value="not-reviewed">Nicht geprueft</option>
|
||||
<option value="compliant">Konform</option>
|
||||
<option value="partial">Teilweise</option>
|
||||
<option value="non-compliant">Nicht konform</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{item.notes && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.evidence.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Nachweise:</span>
|
||||
{item.evidence.map(ev => (
|
||||
<span key={ev} className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.verifiedBy && item.verifiedAt && (
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
Geprueft von {item.verifiedBy} am {item.verifiedAt.toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
{showNotes ? 'Notizen ausblenden' : 'Notizen bearbeiten'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddEvidence}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Nachweis hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNotes && (
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
value={item.notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
placeholder="Notizen hinzufuegen..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
</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-center gap-2 mb-3">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded" />
|
||||
<div className="h-4 w-16 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
<div className="h-5 w-full bg-gray-200 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
interface PastSession {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
auditor_name: string
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
completion_percentage: number
|
||||
total_items: number
|
||||
completed_items: number
|
||||
}
|
||||
import { useAuditChecklist } from './_hooks/useAuditChecklist'
|
||||
import { ChecklistItemCard } from './_components/ChecklistItemCard'
|
||||
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
import { SessionHistory } from './_components/SessionHistory'
|
||||
|
||||
export default function AuditChecklistPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const router = useRouter()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
||||
const notesTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
const [pastSessions, setPastSessions] = useState<PastSession[]>([])
|
||||
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
|
||||
const [generatingPdf, setGeneratingPdf] = useState(false)
|
||||
|
||||
// Fetch checklist from backend on mount
|
||||
useEffect(() => {
|
||||
const fetchChecklist = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// First, try to find an active audit session
|
||||
const sessionsRes = await fetch('/api/sdk/v1/compliance/audit/sessions?status=in_progress')
|
||||
if (sessionsRes.ok) {
|
||||
const sessionsData = await sessionsRes.json()
|
||||
const sessions = sessionsData.sessions || sessionsData
|
||||
if (Array.isArray(sessions) && sessions.length > 0) {
|
||||
const session = sessions[0]
|
||||
setActiveSessionId(session.id)
|
||||
|
||||
// Fetch checklist items for this session
|
||||
const checklistRes = await fetch(`/api/sdk/v1/compliance/audit/checklist/${session.id}`)
|
||||
if (checklistRes.ok) {
|
||||
const checklistData = await checklistRes.json()
|
||||
const items = checklistData.items || checklistData.checklist || checklistData
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
const mapped: SDKChecklistItem[] = items.map((item: Record<string, unknown>) => ({
|
||||
id: (item.id || item.requirement_id || '') as string,
|
||||
requirementId: (item.requirement_id || '') as string,
|
||||
title: (item.title || item.question || '') as string,
|
||||
description: (item.category || item.description || '') as string,
|
||||
status: ((item.status || 'PENDING') as string).toUpperCase() as SDKChecklistItem['status'],
|
||||
notes: (item.notes || item.auditor_notes || '') as string,
|
||||
verifiedBy: (item.verified_by || item.signed_off_by || null) as string | null,
|
||||
verifiedAt: item.verified_at || item.signed_off_at ? new Date((item.verified_at || item.signed_off_at) as string) : null,
|
||||
}))
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: mapped } })
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: load from templates
|
||||
loadFromTemplates()
|
||||
} catch {
|
||||
loadFromTemplates()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromTemplates = () => {
|
||||
if (state.checklist.length > 0) return
|
||||
|
||||
const templatesToLoad = state.requirements.length > 0
|
||||
? checklistTemplates.filter(t =>
|
||||
state.requirements.some(r => r.id === t.requirementId)
|
||||
)
|
||||
: checklistTemplates
|
||||
|
||||
const items: SDKChecklistItem[] = templatesToLoad.map(template => ({
|
||||
id: template.id,
|
||||
requirementId: template.requirementId,
|
||||
title: template.question,
|
||||
description: template.category,
|
||||
status: 'PENDING',
|
||||
notes: '',
|
||||
verifiedBy: null,
|
||||
verifiedAt: null,
|
||||
}))
|
||||
|
||||
if (items.length > 0) {
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: items } })
|
||||
}
|
||||
}
|
||||
|
||||
fetchChecklist()
|
||||
|
||||
// Also fetch all sessions for history view
|
||||
const fetchAllSessions = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/audit/sessions')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const sessions = data.sessions || []
|
||||
setPastSessions(sessions)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
fetchAllSessions()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Convert SDK checklist items to display items
|
||||
const displayItems: DisplayChecklistItem[] = state.checklist.map(item => {
|
||||
const template = checklistTemplates.find(t => t.id === item.id)
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
requirementId: item.requirementId,
|
||||
question: item.title,
|
||||
category: item.description || template?.category || 'Allgemein',
|
||||
status: mapSDKStatusToDisplay(item.status),
|
||||
notes: item.notes,
|
||||
evidence: [],
|
||||
priority: template?.priority || 'medium',
|
||||
verifiedBy: item.verifiedBy,
|
||||
verifiedAt: item.verifiedAt,
|
||||
}
|
||||
})
|
||||
const {
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
activeSessionId,
|
||||
pastSessions,
|
||||
pdfLanguage,
|
||||
setPdfLanguage,
|
||||
generatingPdf,
|
||||
displayItems,
|
||||
handleStatusChange,
|
||||
handleNotesChange,
|
||||
handleExport,
|
||||
handlePdfDownload,
|
||||
handleNewChecklist,
|
||||
} = useAuditChecklist()
|
||||
|
||||
const filteredItems = filter === 'all'
|
||||
? displayItems
|
||||
@@ -417,141 +42,10 @@ export default function AuditChecklistPage() {
|
||||
? Math.round(((compliantCount + partialCount * 0.5) / displayItems.length) * 100)
|
||||
: 0
|
||||
|
||||
const handleStatusChange = async (itemId: string, status: DisplayStatus) => {
|
||||
const sdkStatus = mapDisplayStatusToSDK(status)
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId
|
||||
? {
|
||||
...item,
|
||||
status: sdkStatus,
|
||||
verifiedBy: status !== 'not-reviewed' ? 'Aktueller Benutzer' : null,
|
||||
verifiedAt: status !== 'not-reviewed' ? new Date() : null,
|
||||
}
|
||||
: item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
|
||||
// Persist to backend if we have an active session
|
||||
if (activeSessionId) {
|
||||
try {
|
||||
const item = state.checklist.find(i => i.id === itemId)
|
||||
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: sdkStatus === 'PASSED' ? 'compliant' : sdkStatus === 'FAILED' ? 'non_compliant' : sdkStatus === 'NOT_APPLICABLE' ? 'partially_compliant' : 'not_assessed',
|
||||
auditor_notes: item?.notes || '',
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotesChange = useCallback((itemId: string, notes: string) => {
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId ? { ...item, notes } : item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
|
||||
// Debounced persistence to backend
|
||||
if (notesTimerRef.current[itemId]) {
|
||||
clearTimeout(notesTimerRef.current[itemId])
|
||||
}
|
||||
notesTimerRef.current[itemId] = setTimeout(async () => {
|
||||
if (activeSessionId) {
|
||||
const item = state.checklist.find(i => i.id === itemId)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes }),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [state.checklist, activeSessionId, dispatch])
|
||||
|
||||
const handleExport = () => {
|
||||
const exportData = displayItems.map(item => ({
|
||||
id: item.id,
|
||||
requirementId: item.requirementId,
|
||||
question: item.question,
|
||||
category: item.category,
|
||||
status: item.status,
|
||||
notes: item.notes,
|
||||
priority: item.priority,
|
||||
verifiedBy: item.verifiedBy,
|
||||
verifiedAt: item.verifiedAt?.toISOString() || null,
|
||||
}))
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-checklist-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handlePdfDownload = async () => {
|
||||
if (!activeSessionId) {
|
||||
setError('Kein aktives Audit vorhanden. Erstellen Sie zuerst eine Checkliste.')
|
||||
return
|
||||
}
|
||||
setGeneratingPdf(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${activeSessionId}/report/pdf?language=${pdfLanguage}`)
|
||||
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-checklist-${activeSessionId}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
|
||||
} finally {
|
||||
setGeneratingPdf(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChecklist = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch('/api/sdk/v1/compliance/audit/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `Compliance Audit ${new Date().toLocaleDateString('de-DE')}`,
|
||||
auditor_name: 'Aktueller Benutzer',
|
||||
regulation_codes: ['GDPR'],
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
// Reload data
|
||||
window.location.reload()
|
||||
} else {
|
||||
setError('Fehler beim Erstellen der neuen Checkliste')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['audit-checklist']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="audit-checklist"
|
||||
title={stepInfo.title}
|
||||
@@ -595,7 +89,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
</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>
|
||||
@@ -603,7 +96,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requirements Alert */}
|
||||
{state.requirements.length === 0 && !loading && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -620,7 +112,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checklist Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -649,7 +140,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
@@ -669,7 +159,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'not-reviewed', 'non-compliant', 'partial', 'compliant'].map(f => (
|
||||
@@ -690,10 +179,8 @@ export default function AuditChecklistPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* Checklist Items */}
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{filteredItems.map(item => (
|
||||
@@ -720,61 +207,7 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session History */}
|
||||
{pastSessions.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Vergangene Audit-Sessions</h3>
|
||||
<div className="space-y-3">
|
||||
{pastSessions
|
||||
.filter(s => s.id !== activeSessionId)
|
||||
.map(session => {
|
||||
const statusBadge: Record<string, string> = {
|
||||
draft: 'bg-slate-100 text-slate-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
const statusLabel: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/sdk/audit-report/${session.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusBadge[session.status] || ''}`}>
|
||||
{statusLabel[session.status] || session.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{session.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(session.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{session.completed_items}/{session.total_items} Punkte
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
session.completion_percentage >= 80 ? 'text-green-600' :
|
||||
session.completion_percentage >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{session.completion_percentage}%
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SessionHistory pastSessions={pastSessions} activeSessionId={activeSessionId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CookieBannerConfig, SupportedLanguage } from '@/lib/sdk/einwilligungen/types'
|
||||
|
||||
interface BannerPreviewProps {
|
||||
config: CookieBannerConfig | null
|
||||
language: SupportedLanguage
|
||||
device: 'desktop' | 'tablet' | 'mobile'
|
||||
}
|
||||
|
||||
export function BannerPreview({ config, language, device }: BannerPreviewProps) {
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-xl">
|
||||
<p className="text-slate-400">Konfiguration wird geladen...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isDark = config.styling.theme === 'DARK'
|
||||
const bgColor = isDark ? '#1e293b' : config.styling.backgroundColor || '#ffffff'
|
||||
const textColor = isDark ? '#f1f5f9' : config.styling.textColor || '#1e293b'
|
||||
|
||||
const deviceWidths = { desktop: '100%', tablet: '768px', mobile: '375px' }
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border rounded-xl overflow-hidden"
|
||||
style={{ maxWidth: deviceWidths[device], margin: '0 auto' }}
|
||||
>
|
||||
<div className="bg-slate-100 h-8 flex items-center px-3 gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||
<div className="flex-1 bg-white rounded h-5 mx-4" />
|
||||
</div>
|
||||
|
||||
<div className="relative bg-slate-50 min-h-[400px]">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-4 bg-slate-200 rounded w-3/4" />
|
||||
<div className="h-4 bg-slate-200 rounded w-1/2" />
|
||||
<div className="h-32 bg-slate-200 rounded" />
|
||||
<div className="h-4 bg-slate-200 rounded w-2/3" />
|
||||
<div className="h-4 bg-slate-200 rounded w-1/2" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
|
||||
<div
|
||||
className={`absolute ${
|
||||
config.styling.position === 'TOP'
|
||||
? 'top-0 left-0 right-0'
|
||||
: config.styling.position === 'CENTER'
|
||||
? 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'
|
||||
: 'bottom-0 left-0 right-0'
|
||||
}`}
|
||||
style={{
|
||||
maxWidth: config.styling.maxWidth,
|
||||
margin: config.styling.position === 'CENTER' ? '0' : '16px auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="shadow-xl"
|
||||
style={{
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
borderRadius: config.styling.borderRadius,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<h3 className="font-semibold text-lg mb-2">{config.texts.title[language]}</h3>
|
||||
<p className="text-sm opacity-80 mb-4">{config.texts.description[language]}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<button
|
||||
style={{ background: config.styling.secondaryColor }}
|
||||
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{config.texts.rejectAll[language]}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
style={{ background: config.styling.secondaryColor }}
|
||||
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{config.texts.customize[language]}
|
||||
</button>
|
||||
<button
|
||||
style={{ background: config.styling.primaryColor, color: 'white' }}
|
||||
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{config.texts.acceptAll[language]}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="border-t pt-3 mt-3 space-y-2" style={{ borderColor: 'rgba(128,128,128,0.2)' }}>
|
||||
{config.categories.map((cat) => (
|
||||
<div key={cat.id} className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{cat.name[language]}</div>
|
||||
<div className="text-xs opacity-60">{cat.description[language]}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-10 h-6 rounded-full relative ${
|
||||
cat.isRequired || cat.defaultEnabled ? '' : 'opacity-50'
|
||||
}`}
|
||||
style={{
|
||||
background: cat.isRequired || cat.defaultEnabled
|
||||
? config.styling.primaryColor
|
||||
: 'rgba(128,128,128,0.3)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 w-4 h-4 bg-white rounded-full transition-all"
|
||||
style={{ left: cat.isRequired || cat.defaultEnabled ? '20px' : '4px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
style={{ background: config.styling.primaryColor, color: 'white' }}
|
||||
className="w-full px-4 py-2 rounded-lg text-sm font-medium mt-2"
|
||||
>
|
||||
{config.texts.save[language]}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a href="#" className="block text-xs mt-3" style={{ color: config.styling.primaryColor }}>
|
||||
{config.texts.privacyPolicyLink[language]}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { CookieBannerConfig, SupportedLanguage } from '@/lib/sdk/einwilligungen/types'
|
||||
|
||||
interface CategoryListProps {
|
||||
config: CookieBannerConfig | null
|
||||
language: SupportedLanguage
|
||||
}
|
||||
|
||||
export function CategoryList({ config, language }: CategoryListProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
if (!config) return null
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{config.categories.map((cat) => {
|
||||
const isExpanded = expandedCategories.has(cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleCategory(cat.id)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
cat.isRequired ? 'bg-green-500' : 'bg-amber-500'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-slate-900">{cat.name[language]}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{cat.cookies.length} Cookie(s) | {cat.dataPointIds.length} Datenpunkt(e)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{cat.isRequired && (
|
||||
<span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">
|
||||
Erforderlich
|
||||
</span>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-slate-100 bg-slate-50">
|
||||
<p className="text-sm text-slate-600 py-3">{cat.description[language]}</p>
|
||||
{cat.cookies.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase">Cookies</h4>
|
||||
<div className="space-y-1">
|
||||
{cat.cookies.map((cookie, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-2 bg-white rounded border border-slate-200"
|
||||
>
|
||||
<div>
|
||||
<span className="font-mono text-sm text-slate-700">{cookie.name}</span>
|
||||
<span className="text-xs text-slate-400 ml-2">({cookie.provider})</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{cookie.expiry}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { useEinwilligungen } from '@/lib/sdk/einwilligungen/context'
|
||||
import {
|
||||
generateCookieBannerConfig,
|
||||
DEFAULT_COOKIE_BANNER_TEXTS,
|
||||
DEFAULT_COOKIE_BANNER_STYLING,
|
||||
} from '@/lib/sdk/einwilligungen/generator/cookie-banner'
|
||||
import {
|
||||
CookieBannerStyling,
|
||||
CookieBannerTexts,
|
||||
SupportedLanguage,
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
import { Cookie, Settings, Palette, Code, Monitor, Smartphone, Tablet } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { StylingForm } from './StylingForm'
|
||||
import { TextsForm } from './TextsForm'
|
||||
import { BannerPreview } from './BannerPreview'
|
||||
import { EmbedCodeViewer } from './EmbedCodeViewer'
|
||||
import { CategoryList } from './CategoryList'
|
||||
|
||||
export function CookieBannerContent() {
|
||||
const { state } = useSDK()
|
||||
const { allDataPoints } = useEinwilligungen()
|
||||
|
||||
const [styling, setStyling] = useState<CookieBannerStyling>(DEFAULT_COOKIE_BANNER_STYLING)
|
||||
const [texts, setTexts] = useState<CookieBannerTexts>(DEFAULT_COOKIE_BANNER_TEXTS)
|
||||
const [language, setLanguage] = useState<SupportedLanguage>('de')
|
||||
const [activeTab, setActiveTab] = useState<'styling' | 'texts' | 'embed' | 'categories'>('styling')
|
||||
const [device, setDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop')
|
||||
|
||||
const config = useMemo(() => {
|
||||
return generateCookieBannerConfig(state.tenantId || 'demo', allDataPoints, texts, styling)
|
||||
}, [state.tenantId, allDataPoints, texts, styling])
|
||||
|
||||
const cookieDataPoints = useMemo(
|
||||
() => allDataPoints.filter((dp) => dp.cookieCategory !== null),
|
||||
[allDataPoints]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href="/sdk/einwilligungen/catalog"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zum Katalog
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Cookie-Banner Konfiguration</h1>
|
||||
<p className="text-slate-600 mt-1">Konfigurieren Sie Ihren DSGVO-konformen Cookie-Banner.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as SupportedLanguage)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500">Kategorien</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{config?.categories.length || 0}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500">Cookie-Datenpunkte</div>
|
||||
<div className="text-2xl font-bold text-indigo-600">{cookieDataPoints.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4">
|
||||
<div className="text-sm text-green-600">Erforderlich</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{config?.categories.filter((c) => c.isRequired).length || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-amber-200 p-4">
|
||||
<div className="text-sm text-amber-600">Optional</div>
|
||||
<div className="text-2xl font-bold text-amber-600">
|
||||
{config?.categories.filter((c) => !c.isRequired).length || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex border-b border-slate-200">
|
||||
{[
|
||||
{ id: 'styling', label: 'Design', icon: Palette },
|
||||
{ id: 'texts', label: 'Texte', icon: Settings },
|
||||
{ id: 'categories', label: 'Kategorien', icon: Cookie },
|
||||
{ id: 'embed', label: 'Embed-Code', icon: Code },
|
||||
].map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id as typeof activeTab)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === id
|
||||
? 'text-indigo-600 border-indigo-600'
|
||||
: 'text-slate-600 border-transparent hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
{activeTab === 'styling' && <StylingForm styling={styling} onChange={setStyling} />}
|
||||
{activeTab === 'texts' && <TextsForm texts={texts} language={language} onChange={setTexts} />}
|
||||
{activeTab === 'categories' && <CategoryList config={config} language={language} />}
|
||||
{activeTab === 'embed' && <EmbedCodeViewer config={config} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Vorschau</h3>
|
||||
<div className="flex items-center border border-slate-200 rounded-lg overflow-hidden">
|
||||
{[
|
||||
{ id: 'desktop', icon: Monitor },
|
||||
{ id: 'tablet', icon: Tablet },
|
||||
{ id: 'mobile', icon: Smartphone },
|
||||
].map(({ id, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setDevice(id as typeof device)}
|
||||
className={`p-2 ${
|
||||
device === id ? 'bg-indigo-50 text-indigo-600' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<BannerPreview config={config} language={language} device={device} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import { CookieBannerConfig } from '@/lib/sdk/einwilligungen/types'
|
||||
import { generateEmbedCode } from '@/lib/sdk/einwilligungen/generator/cookie-banner'
|
||||
|
||||
interface EmbedCodeViewerProps {
|
||||
config: CookieBannerConfig | null
|
||||
}
|
||||
|
||||
export function EmbedCodeViewer({ config }: EmbedCodeViewerProps) {
|
||||
const [activeTab, setActiveTab] = useState<'script' | 'html' | 'css' | 'js'>('script')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const embedCode = useMemo(() => {
|
||||
if (!config) return null
|
||||
return generateEmbedCode(config, '/datenschutz')
|
||||
}, [config])
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!embedCode) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 bg-slate-100 rounded-xl">
|
||||
<p className="text-slate-400">Embed-Code wird generiert...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'script', label: 'Script-Tag', content: embedCode.scriptTag },
|
||||
{ id: 'html', label: 'HTML', content: embedCode.html },
|
||||
{ id: 'css', label: 'CSS', content: embedCode.css },
|
||||
{ id: 'js', label: 'JavaScript', content: embedCode.js },
|
||||
] as const
|
||||
|
||||
const currentContent = tabs.find((t) => t.id === activeTab)?.content || ''
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-xl overflow-hidden">
|
||||
<div className="flex border-b border-slate-200 bg-slate-50">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-indigo-600 border-b-2 border-indigo-600 -mb-px'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<pre className="p-4 bg-slate-900 text-slate-100 text-sm font-mono overflow-x-auto max-h-[400px]">
|
||||
{currentContent}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copyToClipboard(currentContent)}
|
||||
className="absolute top-3 right-3 flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-green-400" />
|
||||
Kopiert
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Kopieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'script' && (
|
||||
<div className="p-4 bg-amber-50 border-t border-amber-200">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Integration:</strong> Fuegen Sie den Script-Tag in den{' '}
|
||||
<code className="bg-amber-100 px-1 rounded"><head></code> oder vor dem
|
||||
schliessenden{' '}
|
||||
<code className="bg-amber-100 px-1 rounded"></body></code>-Tag ein.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { CookieBannerStyling } from '@/lib/sdk/einwilligungen/types'
|
||||
|
||||
interface StylingFormProps {
|
||||
styling: CookieBannerStyling
|
||||
onChange: (styling: CookieBannerStyling) => void
|
||||
}
|
||||
|
||||
export function StylingForm({ styling, onChange }: StylingFormProps) {
|
||||
const handleChange = (field: keyof CookieBannerStyling, value: string | number) => {
|
||||
onChange({ ...styling, [field]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Position</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['BOTTOM', 'TOP', 'CENTER'] as const).map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
onClick={() => handleChange('position', pos)}
|
||||
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
|
||||
styling.position === pos
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{pos === 'BOTTOM' ? 'Unten' : pos === 'TOP' ? 'Oben' : 'Zentriert'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Theme</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['LIGHT', 'DARK'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => handleChange('theme', theme)}
|
||||
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
|
||||
styling.theme === theme
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{theme === 'LIGHT' ? 'Hell' : 'Dunkel'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Primaerfarbe</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={styling.primaryColor}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={styling.primaryColor}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Sekundaerfarbe</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={styling.secondaryColor || '#f1f5f9'}
|
||||
onChange={(e) => handleChange('secondaryColor', e.target.value)}
|
||||
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={styling.secondaryColor || '#f1f5f9'}
|
||||
onChange={(e) => handleChange('secondaryColor', e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Eckenradius (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={32}
|
||||
value={styling.borderRadius}
|
||||
onChange={(e) => handleChange('borderRadius', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Breite (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={320}
|
||||
max={800}
|
||||
value={styling.maxWidth}
|
||||
onChange={(e) => handleChange('maxWidth', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { CookieBannerTexts, SupportedLanguage } from '@/lib/sdk/einwilligungen/types'
|
||||
|
||||
interface TextsFormProps {
|
||||
texts: CookieBannerTexts
|
||||
language: SupportedLanguage
|
||||
onChange: (texts: CookieBannerTexts) => void
|
||||
}
|
||||
|
||||
export function TextsForm({ texts, language, onChange }: TextsFormProps) {
|
||||
const handleChange = (field: keyof CookieBannerTexts, value: string) => {
|
||||
onChange({
|
||||
...texts,
|
||||
[field]: { ...texts[field], [language]: value },
|
||||
})
|
||||
}
|
||||
|
||||
const fields: { key: keyof CookieBannerTexts; label: string; multiline?: boolean }[] = [
|
||||
{ key: 'title', label: 'Titel' },
|
||||
{ key: 'description', label: 'Beschreibung', multiline: true },
|
||||
{ key: 'acceptAll', label: 'Alle akzeptieren Button' },
|
||||
{ key: 'rejectAll', label: 'Nur notwendige Button' },
|
||||
{ key: 'customize', label: 'Einstellungen Button' },
|
||||
{ key: 'save', label: 'Speichern Button' },
|
||||
{ key: 'privacyPolicyLink', label: 'Datenschutz-Link Text' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{fields.map(({ key, label, multiline }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">{label}</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={texts[key][language]}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={texts[key][language]}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,753 +6,8 @@
|
||||
* Konfiguriert den Cookie-Banner basierend auf dem Datenpunktkatalog.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
EinwilligungenProvider,
|
||||
useEinwilligungen,
|
||||
} from '@/lib/sdk/einwilligungen/context'
|
||||
import {
|
||||
generateCookieBannerConfig,
|
||||
generateEmbedCode,
|
||||
DEFAULT_COOKIE_BANNER_TEXTS,
|
||||
DEFAULT_COOKIE_BANNER_STYLING,
|
||||
} from '@/lib/sdk/einwilligungen/generator/cookie-banner'
|
||||
import {
|
||||
CookieBannerConfig,
|
||||
CookieBannerStyling,
|
||||
CookieBannerTexts,
|
||||
SupportedLanguage,
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
import {
|
||||
Cookie,
|
||||
Settings,
|
||||
Palette,
|
||||
Code,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
ArrowLeft,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Tablet,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// =============================================================================
|
||||
// STYLING FORM
|
||||
// =============================================================================
|
||||
|
||||
interface StylingFormProps {
|
||||
styling: CookieBannerStyling
|
||||
onChange: (styling: CookieBannerStyling) => void
|
||||
}
|
||||
|
||||
function StylingForm({ styling, onChange }: StylingFormProps) {
|
||||
const handleChange = (field: keyof CookieBannerStyling, value: string | number) => {
|
||||
onChange({ ...styling, [field]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Position */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Position
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['BOTTOM', 'TOP', 'CENTER'] as const).map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
onClick={() => handleChange('position', pos)}
|
||||
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
|
||||
styling.position === pos
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{pos === 'BOTTOM' ? 'Unten' : pos === 'TOP' ? 'Oben' : 'Zentriert'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Theme
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['LIGHT', 'DARK'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => handleChange('theme', theme)}
|
||||
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
|
||||
styling.theme === theme
|
||||
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{theme === 'LIGHT' ? 'Hell' : 'Dunkel'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Primaerfarbe
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={styling.primaryColor}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={styling.primaryColor}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Sekundaerfarbe
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={styling.secondaryColor || '#f1f5f9'}
|
||||
onChange={(e) => handleChange('secondaryColor', e.target.value)}
|
||||
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={styling.secondaryColor || '#f1f5f9'}
|
||||
onChange={(e) => handleChange('secondaryColor', e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border Radius & Max Width */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Eckenradius (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={32}
|
||||
value={styling.borderRadius}
|
||||
onChange={(e) => handleChange('borderRadius', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Max. Breite (px)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={320}
|
||||
max={800}
|
||||
value={styling.maxWidth}
|
||||
onChange={(e) => handleChange('maxWidth', parseInt(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TEXTS FORM
|
||||
// =============================================================================
|
||||
|
||||
interface TextsFormProps {
|
||||
texts: CookieBannerTexts
|
||||
language: SupportedLanguage
|
||||
onChange: (texts: CookieBannerTexts) => void
|
||||
}
|
||||
|
||||
function TextsForm({ texts, language, onChange }: TextsFormProps) {
|
||||
const handleChange = (field: keyof CookieBannerTexts, value: string) => {
|
||||
onChange({
|
||||
...texts,
|
||||
[field]: { ...texts[field], [language]: value },
|
||||
})
|
||||
}
|
||||
|
||||
const fields: { key: keyof CookieBannerTexts; label: string; multiline?: boolean }[] = [
|
||||
{ key: 'title', label: 'Titel' },
|
||||
{ key: 'description', label: 'Beschreibung', multiline: true },
|
||||
{ key: 'acceptAll', label: 'Alle akzeptieren Button' },
|
||||
{ key: 'rejectAll', label: 'Nur notwendige Button' },
|
||||
{ key: 'customize', label: 'Einstellungen Button' },
|
||||
{ key: 'save', label: 'Speichern Button' },
|
||||
{ key: 'privacyPolicyLink', label: 'Datenschutz-Link Text' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{fields.map(({ key, label, multiline }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
value={texts[key][language]}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={texts[key][language]}
|
||||
onChange={(e) => handleChange(key, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BANNER PREVIEW
|
||||
// =============================================================================
|
||||
|
||||
interface BannerPreviewProps {
|
||||
config: CookieBannerConfig | null
|
||||
language: SupportedLanguage
|
||||
device: 'desktop' | 'tablet' | 'mobile'
|
||||
}
|
||||
|
||||
function BannerPreview({ config, language, device }: BannerPreviewProps) {
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-xl">
|
||||
<p className="text-slate-400">Konfiguration wird geladen...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isDark = config.styling.theme === 'DARK'
|
||||
const bgColor = isDark ? '#1e293b' : config.styling.backgroundColor || '#ffffff'
|
||||
const textColor = isDark ? '#f1f5f9' : config.styling.textColor || '#1e293b'
|
||||
|
||||
const deviceWidths = {
|
||||
desktop: '100%',
|
||||
tablet: '768px',
|
||||
mobile: '375px',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border rounded-xl overflow-hidden"
|
||||
style={{
|
||||
maxWidth: deviceWidths[device],
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
{/* Simulated Browser */}
|
||||
<div className="bg-slate-100 h-8 flex items-center px-3 gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-400" />
|
||||
<div className="flex-1 bg-white rounded h-5 mx-4" />
|
||||
</div>
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="relative bg-slate-50 min-h-[400px]">
|
||||
{/* Placeholder Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-4 bg-slate-200 rounded w-3/4" />
|
||||
<div className="h-4 bg-slate-200 rounded w-1/2" />
|
||||
<div className="h-32 bg-slate-200 rounded" />
|
||||
<div className="h-4 bg-slate-200 rounded w-2/3" />
|
||||
<div className="h-4 bg-slate-200 rounded w-1/2" />
|
||||
</div>
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-black/40" />
|
||||
|
||||
{/* Cookie Banner */}
|
||||
<div
|
||||
className={`absolute ${
|
||||
config.styling.position === 'TOP'
|
||||
? 'top-0 left-0 right-0'
|
||||
: config.styling.position === 'CENTER'
|
||||
? 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'
|
||||
: 'bottom-0 left-0 right-0'
|
||||
}`}
|
||||
style={{
|
||||
maxWidth: config.styling.maxWidth,
|
||||
margin: config.styling.position === 'CENTER' ? '0' : '16px auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="shadow-xl"
|
||||
style={{
|
||||
background: bgColor,
|
||||
color: textColor,
|
||||
borderRadius: config.styling.borderRadius,
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
{config.texts.title[language]}
|
||||
</h3>
|
||||
<p className="text-sm opacity-80 mb-4">
|
||||
{config.texts.description[language]}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<button
|
||||
style={{ background: config.styling.secondaryColor }}
|
||||
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{config.texts.rejectAll[language]}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
style={{ background: config.styling.secondaryColor }}
|
||||
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{config.texts.customize[language]}
|
||||
</button>
|
||||
<button
|
||||
style={{ background: config.styling.primaryColor, color: 'white' }}
|
||||
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
|
||||
>
|
||||
{config.texts.acceptAll[language]}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="border-t pt-3 mt-3 space-y-2" style={{ borderColor: 'rgba(128,128,128,0.2)' }}>
|
||||
{config.categories.map((cat) => (
|
||||
<div key={cat.id} className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<div className="font-medium text-sm">{cat.name[language]}</div>
|
||||
<div className="text-xs opacity-60">{cat.description[language]}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`w-10 h-6 rounded-full relative ${
|
||||
cat.isRequired || cat.defaultEnabled
|
||||
? ''
|
||||
: 'opacity-50'
|
||||
}`}
|
||||
style={{
|
||||
background: cat.isRequired || cat.defaultEnabled
|
||||
? config.styling.primaryColor
|
||||
: 'rgba(128,128,128,0.3)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 w-4 h-4 bg-white rounded-full transition-all"
|
||||
style={{
|
||||
left: cat.isRequired || cat.defaultEnabled ? '20px' : '4px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
style={{ background: config.styling.primaryColor, color: 'white' }}
|
||||
className="w-full px-4 py-2 rounded-lg text-sm font-medium mt-2"
|
||||
>
|
||||
{config.texts.save[language]}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a
|
||||
href="#"
|
||||
className="block text-xs mt-3"
|
||||
style={{ color: config.styling.primaryColor }}
|
||||
>
|
||||
{config.texts.privacyPolicyLink[language]}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EMBED CODE VIEWER
|
||||
// =============================================================================
|
||||
|
||||
interface EmbedCodeViewerProps {
|
||||
config: CookieBannerConfig | null
|
||||
}
|
||||
|
||||
function EmbedCodeViewer({ config }: EmbedCodeViewerProps) {
|
||||
const [activeTab, setActiveTab] = useState<'script' | 'html' | 'css' | 'js'>('script')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const embedCode = useMemo(() => {
|
||||
if (!config) return null
|
||||
return generateEmbedCode(config, '/datenschutz')
|
||||
}, [config])
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!embedCode) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 bg-slate-100 rounded-xl">
|
||||
<p className="text-slate-400">Embed-Code wird generiert...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'script', label: 'Script-Tag', content: embedCode.scriptTag },
|
||||
{ id: 'html', label: 'HTML', content: embedCode.html },
|
||||
{ id: 'css', label: 'CSS', content: embedCode.css },
|
||||
{ id: 'js', label: 'JavaScript', content: embedCode.js },
|
||||
] as const
|
||||
|
||||
const currentContent = tabs.find((t) => t.id === activeTab)?.content || ''
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-xl overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200 bg-slate-50">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-indigo-600 border-b-2 border-indigo-600 -mb-px'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Code */}
|
||||
<div className="relative">
|
||||
<pre className="p-4 bg-slate-900 text-slate-100 text-sm font-mono overflow-x-auto max-h-[400px]">
|
||||
{currentContent}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copyToClipboard(currentContent)}
|
||||
className="absolute top-3 right-3 flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-green-400" />
|
||||
Kopiert
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Kopieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Integration Instructions */}
|
||||
{activeTab === 'script' && (
|
||||
<div className="p-4 bg-amber-50 border-t border-amber-200">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>Integration:</strong> Fuegen Sie den Script-Tag in den{' '}
|
||||
<code className="bg-amber-100 px-1 rounded"><head></code> oder vor dem
|
||||
schliessenden{' '}
|
||||
<code className="bg-amber-100 px-1 rounded"></body></code>-Tag ein.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CATEGORY LIST
|
||||
// =============================================================================
|
||||
|
||||
interface CategoryListProps {
|
||||
config: CookieBannerConfig | null
|
||||
language: SupportedLanguage
|
||||
}
|
||||
|
||||
function CategoryList({ config, language }: CategoryListProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
if (!config) return null
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{config.categories.map((cat) => {
|
||||
const isExpanded = expandedCategories.has(cat.id)
|
||||
return (
|
||||
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleCategory(cat.id)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
cat.isRequired ? 'bg-green-500' : 'bg-amber-500'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-slate-900">{cat.name[language]}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{cat.cookies.length} Cookie(s) | {cat.dataPointIds.length} Datenpunkt(e)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{cat.isRequired && (
|
||||
<span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">
|
||||
Erforderlich
|
||||
</span>
|
||||
)}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-5 h-5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 border-t border-slate-100 bg-slate-50">
|
||||
<p className="text-sm text-slate-600 py-3">{cat.description[language]}</p>
|
||||
|
||||
{cat.cookies.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase">Cookies</h4>
|
||||
<div className="space-y-1">
|
||||
{cat.cookies.map((cookie, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-2 bg-white rounded border border-slate-200"
|
||||
>
|
||||
<div>
|
||||
<span className="font-mono text-sm text-slate-700">{cookie.name}</span>
|
||||
<span className="text-xs text-slate-400 ml-2">({cookie.provider})</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{cookie.expiry}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN CONTENT
|
||||
// =============================================================================
|
||||
|
||||
function CookieBannerContent() {
|
||||
const { state } = useSDK()
|
||||
const { allDataPoints } = useEinwilligungen()
|
||||
|
||||
const [styling, setStyling] = useState<CookieBannerStyling>(DEFAULT_COOKIE_BANNER_STYLING)
|
||||
const [texts, setTexts] = useState<CookieBannerTexts>(DEFAULT_COOKIE_BANNER_TEXTS)
|
||||
const [language, setLanguage] = useState<SupportedLanguage>('de')
|
||||
const [activeTab, setActiveTab] = useState<'styling' | 'texts' | 'embed' | 'categories'>('styling')
|
||||
const [device, setDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop')
|
||||
|
||||
const config = useMemo(() => {
|
||||
return generateCookieBannerConfig(
|
||||
state.tenantId || 'demo',
|
||||
allDataPoints,
|
||||
texts,
|
||||
styling
|
||||
)
|
||||
}, [state.tenantId, allDataPoints, texts, styling])
|
||||
|
||||
const cookieDataPoints = useMemo(
|
||||
() => allDataPoints.filter((dp) => dp.cookieCategory !== null),
|
||||
[allDataPoints]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/sdk/einwilligungen/catalog"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Zurueck zum Katalog
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Cookie-Banner Konfiguration</h1>
|
||||
<p className="text-slate-600 mt-1">
|
||||
Konfigurieren Sie Ihren DSGVO-konformen Cookie-Banner.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value as SupportedLanguage)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500">Kategorien</div>
|
||||
<div className="text-2xl font-bold text-slate-900">{config?.categories.length || 0}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-sm text-slate-500">Cookie-Datenpunkte</div>
|
||||
<div className="text-2xl font-bold text-indigo-600">{cookieDataPoints.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4">
|
||||
<div className="text-sm text-green-600">Erforderlich</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{config?.categories.filter((c) => c.isRequired).length || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-amber-200 p-4">
|
||||
<div className="text-sm text-amber-600">Optional</div>
|
||||
<div className="text-2xl font-bold text-amber-600">
|
||||
{config?.categories.filter((c) => !c.isRequired).length || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left: Configuration */}
|
||||
<div className="space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-slate-200">
|
||||
{[
|
||||
{ id: 'styling', label: 'Design', icon: Palette },
|
||||
{ id: 'texts', label: 'Texte', icon: Settings },
|
||||
{ id: 'categories', label: 'Kategorien', icon: Cookie },
|
||||
{ id: 'embed', label: 'Embed-Code', icon: Code },
|
||||
].map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActiveTab(id as typeof activeTab)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === id
|
||||
? 'text-indigo-600 border-indigo-600'
|
||||
: 'text-slate-600 border-transparent hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
{activeTab === 'styling' && (
|
||||
<StylingForm styling={styling} onChange={setStyling} />
|
||||
)}
|
||||
{activeTab === 'texts' && (
|
||||
<TextsForm texts={texts} language={language} onChange={setTexts} />
|
||||
)}
|
||||
{activeTab === 'categories' && (
|
||||
<CategoryList config={config} language={language} />
|
||||
)}
|
||||
{activeTab === 'embed' && <EmbedCodeViewer config={config} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview */}
|
||||
<div className="space-y-4">
|
||||
{/* Device Selector */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Vorschau</h3>
|
||||
<div className="flex items-center border border-slate-200 rounded-lg overflow-hidden">
|
||||
{[
|
||||
{ id: 'desktop', icon: Monitor },
|
||||
{ id: 'tablet', icon: Tablet },
|
||||
{ id: 'mobile', icon: Smartphone },
|
||||
].map(({ id, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setDevice(id as typeof device)}
|
||||
className={`p-2 ${
|
||||
device === id
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<BannerPreview config={config} language={language} device={device} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
import { EinwilligungenProvider } from '@/lib/sdk/einwilligungen/context'
|
||||
import { CookieBannerContent } from './_components/CookieBannerContent'
|
||||
|
||||
export default function CookieBannerPage() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { Escalation, priorityColors, priorityLabels, statusColors, statusLabels, categoryLabels, formatDate } from './types'
|
||||
|
||||
interface CardProps {
|
||||
escalation: Escalation
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function EscalationCard({ escalation, onClick }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`bg-white rounded-xl border-2 p-6 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
escalation.priority === 'critical' ? 'border-red-300' :
|
||||
escalation.priority === 'high' ? 'border-orange-300' :
|
||||
escalation.status === 'resolved' || escalation.status === 'closed' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${priorityColors[escalation.priority]}`}>
|
||||
{priorityLabels[escalation.priority]}
|
||||
</span>
|
||||
{escalation.category && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-purple-100 text-purple-700">
|
||||
{categoryLabels[escalation.category] || escalation.category}
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[escalation.status]}`}>
|
||||
{statusLabels[escalation.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{escalation.title}</h3>
|
||||
{escalation.description && (
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{escalation.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-400 ml-3 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
{escalation.assignee && (
|
||||
<div>
|
||||
<span className="text-gray-500">Zugewiesen: </span>
|
||||
<span className="font-medium text-gray-700">{escalation.assignee}</span>
|
||||
</div>
|
||||
)}
|
||||
{escalation.due_date && (
|
||||
<div>
|
||||
<span className="text-gray-500">Frist: </span>
|
||||
<span className="font-medium text-gray-700">{formatDate(escalation.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">Erstellt: </span>
|
||||
<span className="font-medium text-gray-700">{formatDate(escalation.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400 font-mono">{escalation.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface CreateModalProps {
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
export function EscalationCreateModal({ onClose, onCreated }: CreateModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [priority, setPriority] = useState('medium')
|
||||
const [category, setCategory] = useState('')
|
||||
const [assignee, setAssignee] = useState('')
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) {
|
||||
setError('Titel ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/escalations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
priority,
|
||||
category: category || null,
|
||||
assignee: assignee.trim() || null,
|
||||
due_date: dueDate || null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || err.error || 'Fehler beim Erstellen')
|
||||
}
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg">
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<h2 className="text-xl font-bold text-gray-900">Neue Eskalation erstellen</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 text-sm px-4 py-2 rounded-lg">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titel <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Kurze Beschreibung der Eskalation"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Detaillierte Beschreibung…"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={e => setPriority(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">— Keine —</option>
|
||||
<option value="dsgvo_breach">DSGVO-Verletzung</option>
|
||||
<option value="ai_act">AI Act</option>
|
||||
<option value="vendor">Vendor</option>
|
||||
<option value="internal">Intern</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zugewiesen an</label>
|
||||
<input
|
||||
type="text"
|
||||
value={assignee}
|
||||
onChange={e => setAssignee(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Name oder Team"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Faelligkeitsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={e => setDueDate(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-100 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Eskalation erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Escalation, priorityColors, priorityLabels, statusColors, statusLabels, categoryLabels, formatDate } from './types'
|
||||
|
||||
interface DrawerProps {
|
||||
escalation: Escalation
|
||||
onClose: () => void
|
||||
onUpdated: () => void
|
||||
}
|
||||
|
||||
export function EscalationDetailDrawer({ escalation, onClose, onUpdated }: DrawerProps) {
|
||||
const [editAssignee, setEditAssignee] = useState(escalation.assignee || '')
|
||||
const [editPriority, setEditPriority] = useState(escalation.priority)
|
||||
const [editDueDate, setEditDueDate] = useState(
|
||||
escalation.due_date ? escalation.due_date.slice(0, 10) : ''
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [statusSaving, setStatusSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
async function handleDeleteEscalation() {
|
||||
if (!window.confirm(`Eskalation "${escalation.title}" wirklich löschen?`)) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/escalations/${escalation.id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
||||
onUpdated()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Löschen fehlgeschlagen:', err)
|
||||
alert('Löschen fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/escalations/${escalation.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
assignee: editAssignee || null,
|
||||
priority: editPriority,
|
||||
due_date: editDueDate || null,
|
||||
}),
|
||||
})
|
||||
onUpdated()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
setStatusSaving(true)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/escalations/${escalation.id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
onUpdated()
|
||||
onClose()
|
||||
} finally {
|
||||
setStatusSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end">
|
||||
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md bg-white shadow-2xl flex flex-col h-full overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-100 flex items-start justify-between">
|
||||
<div className="flex-1 pr-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[escalation.priority]}`}>
|
||||
{priorityLabels[escalation.priority]}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusColors[escalation.status]}`}>
|
||||
{statusLabels[escalation.status]}
|
||||
</span>
|
||||
{escalation.category && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-700">
|
||||
{categoryLabels[escalation.category] || escalation.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{escalation.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
|
||||
<div className="p-6 space-y-6 flex-1">
|
||||
{escalation.description && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Beschreibung</h3>
|
||||
<p className="text-sm text-gray-700">{escalation.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Erstellt</span>
|
||||
<p className="font-medium text-gray-800">{formatDate(escalation.created_at)}</p>
|
||||
</div>
|
||||
{escalation.reporter && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Gemeldet von</span>
|
||||
<p className="font-medium text-gray-800">{escalation.reporter}</p>
|
||||
</div>
|
||||
)}
|
||||
{escalation.source_module && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Quell-Modul</span>
|
||||
<p className="font-medium text-gray-800">{escalation.source_module}</p>
|
||||
</div>
|
||||
)}
|
||||
{escalation.resolved_at && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Geloest am</span>
|
||||
<p className="font-medium text-green-700">{formatDate(escalation.resolved_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Bearbeiten</h3>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Zugewiesen an</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editAssignee}
|
||||
onChange={e => setEditAssignee(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Name oder Team"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Prioritaet</label>
|
||||
<select
|
||||
value={editPriority}
|
||||
onChange={e => setEditPriority(e.target.value as Escalation['priority'])}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Faelligkeit</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editDueDate}
|
||||
onChange={e => setEditDueDate(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={saving}
|
||||
className="w-full py-2 text-sm bg-gray-800 text-white rounded-lg hover:bg-gray-900 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Aenderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Status-Aktionen</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{escalation.status === 'open' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('in_progress')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
In Bearbeitung nehmen
|
||||
</button>
|
||||
)}
|
||||
{escalation.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('escalated')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Eskalieren
|
||||
</button>
|
||||
)}
|
||||
{(escalation.status === 'escalated' || escalation.status === 'in_progress') && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('resolved')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Loesen
|
||||
</button>
|
||||
)}
|
||||
{escalation.status === 'resolved' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('closed')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
)}
|
||||
{escalation.status === 'closed' && (
|
||||
<p className="text-sm text-gray-400 text-center py-2">Eskalation ist geschlossen.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleDeleteEscalation}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Löschen...' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
admin-compliance/app/sdk/escalations/_components/types.ts
Normal file
73
admin-compliance/app/sdk/escalations/_components/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface Escalation {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
priority: 'low' | 'medium' | 'high' | 'critical'
|
||||
status: 'open' | 'in_progress' | 'escalated' | 'resolved' | 'closed'
|
||||
category: string | null
|
||||
assignee: string | null
|
||||
reporter: string | null
|
||||
source_module: string | null
|
||||
due_date: string | null
|
||||
resolved_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface EscalationStats {
|
||||
by_status: Record<string, number>
|
||||
by_priority: Record<string, number>
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export const priorityColors: Record<string, string> = {
|
||||
critical: 'bg-red-500 text-white',
|
||||
high: 'bg-orange-500 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
low: 'bg-green-500 text-white',
|
||||
}
|
||||
|
||||
export const priorityLabels: Record<string, string> = {
|
||||
critical: 'Kritisch',
|
||||
high: 'Hoch',
|
||||
medium: 'Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
|
||||
export const statusColors: Record<string, string> = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
escalated: 'bg-red-100 text-red-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
export const statusLabels: Record<string, string> = {
|
||||
open: 'Offen',
|
||||
in_progress: 'In Bearbeitung',
|
||||
escalated: 'Eskaliert',
|
||||
resolved: 'Geloest',
|
||||
closed: 'Geschlossen',
|
||||
}
|
||||
|
||||
export const categoryLabels: Record<string, string> = {
|
||||
dsgvo_breach: 'DSGVO-Verletzung',
|
||||
ai_act: 'AI Act',
|
||||
vendor: 'Vendor',
|
||||
internal: 'Intern',
|
||||
other: 'Sonstiges',
|
||||
}
|
||||
|
||||
export function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('de-DE')
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Escalation, EscalationStats } from '../_components/types'
|
||||
|
||||
export function useEscalations(filter: string) {
|
||||
const [escalations, setEscalations] = useState<Escalation[]>([])
|
||||
const [stats, setStats] = useState<EscalationStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
async function loadEscalations() {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '100' })
|
||||
if (filter !== 'all' && ['open', 'in_progress', 'escalated', 'resolved', 'closed'].includes(filter)) {
|
||||
params.set('status', filter)
|
||||
} else if (filter !== 'all' && ['low', 'medium', 'high', 'critical'].includes(filter)) {
|
||||
params.set('priority', filter)
|
||||
}
|
||||
const res = await fetch(`/api/sdk/v1/escalations?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setEscalations(data.items || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load escalations', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/escalations/stats')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true)
|
||||
await Promise.all([loadEscalations(), loadStats()])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter])
|
||||
|
||||
return { escalations, stats, loading, loadAll }
|
||||
}
|
||||
@@ -1,623 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import React, { useState } from 'react'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface Escalation {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
priority: 'low' | 'medium' | 'high' | 'critical'
|
||||
status: 'open' | 'in_progress' | 'escalated' | 'resolved' | 'closed'
|
||||
category: string | null
|
||||
assignee: string | null
|
||||
reporter: string | null
|
||||
source_module: string | null
|
||||
due_date: string | null
|
||||
resolved_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface EscalationStats {
|
||||
by_status: Record<string, number>
|
||||
by_priority: Record<string, number>
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: 'bg-red-500 text-white',
|
||||
high: 'bg-orange-500 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
low: 'bg-green-500 text-white',
|
||||
}
|
||||
|
||||
const priorityLabels: Record<string, string> = {
|
||||
critical: 'Kritisch',
|
||||
high: 'Hoch',
|
||||
medium: 'Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
escalated: 'bg-red-100 text-red-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
open: 'Offen',
|
||||
in_progress: 'In Bearbeitung',
|
||||
escalated: 'Eskaliert',
|
||||
resolved: 'Geloest',
|
||||
closed: 'Geschlossen',
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
dsgvo_breach: 'DSGVO-Verletzung',
|
||||
ai_act: 'AI Act',
|
||||
vendor: 'Vendor',
|
||||
internal: 'Intern',
|
||||
other: 'Sonstiges',
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('de-DE')
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CREATE MODAL
|
||||
// =============================================================================
|
||||
|
||||
interface CreateModalProps {
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
function EscalationCreateModal({ onClose, onCreated }: CreateModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [priority, setPriority] = useState('medium')
|
||||
const [category, setCategory] = useState('')
|
||||
const [assignee, setAssignee] = useState('')
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim()) {
|
||||
setError('Titel ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/escalations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
priority,
|
||||
category: category || null,
|
||||
assignee: assignee.trim() || null,
|
||||
due_date: dueDate || null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || err.error || 'Fehler beim Erstellen')
|
||||
}
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg">
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<h2 className="text-xl font-bold text-gray-900">Neue Eskalation erstellen</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 text-sm px-4 py-2 rounded-lg">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titel <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Kurze Beschreibung der Eskalation"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Detaillierte Beschreibung…"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={e => setPriority(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">— Keine —</option>
|
||||
<option value="dsgvo_breach">DSGVO-Verletzung</option>
|
||||
<option value="ai_act">AI Act</option>
|
||||
<option value="vendor">Vendor</option>
|
||||
<option value="internal">Intern</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zugewiesen an</label>
|
||||
<input
|
||||
type="text"
|
||||
value={assignee}
|
||||
onChange={e => setAssignee(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Name oder Team"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Faelligkeitsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={e => setDueDate(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-100 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Eskalation erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DETAIL DRAWER
|
||||
// =============================================================================
|
||||
|
||||
interface DrawerProps {
|
||||
escalation: Escalation
|
||||
onClose: () => void
|
||||
onUpdated: () => void
|
||||
}
|
||||
|
||||
function EscalationDetailDrawer({ escalation, onClose, onUpdated }: DrawerProps) {
|
||||
const [editAssignee, setEditAssignee] = useState(escalation.assignee || '')
|
||||
const [editPriority, setEditPriority] = useState(escalation.priority)
|
||||
const [editDueDate, setEditDueDate] = useState(
|
||||
escalation.due_date ? escalation.due_date.slice(0, 10) : ''
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [statusSaving, setStatusSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
async function handleDeleteEscalation() {
|
||||
if (!window.confirm(`Eskalation "${escalation.title}" wirklich löschen?`)) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/escalations/${escalation.id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
||||
onUpdated()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Löschen fehlgeschlagen:', err)
|
||||
alert('Löschen fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/escalations/${escalation.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
assignee: editAssignee || null,
|
||||
priority: editPriority,
|
||||
due_date: editDueDate || null,
|
||||
}),
|
||||
})
|
||||
onUpdated()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
setStatusSaving(true)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/escalations/${escalation.id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
onUpdated()
|
||||
onClose()
|
||||
} finally {
|
||||
setStatusSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
|
||||
{/* Panel */}
|
||||
<div className="relative w-full max-w-md bg-white shadow-2xl flex flex-col h-full overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-100 flex items-start justify-between">
|
||||
<div className="flex-1 pr-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[escalation.priority]}`}>
|
||||
{priorityLabels[escalation.priority]}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusColors[escalation.status]}`}>
|
||||
{statusLabels[escalation.status]}
|
||||
</span>
|
||||
{escalation.category && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-700">
|
||||
{categoryLabels[escalation.category] || escalation.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{escalation.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
|
||||
<div className="p-6 space-y-6 flex-1">
|
||||
{/* Description */}
|
||||
{escalation.description && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Beschreibung</h3>
|
||||
<p className="text-sm text-gray-700">{escalation.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Erstellt</span>
|
||||
<p className="font-medium text-gray-800">{formatDate(escalation.created_at)}</p>
|
||||
</div>
|
||||
{escalation.reporter && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Gemeldet von</span>
|
||||
<p className="font-medium text-gray-800">{escalation.reporter}</p>
|
||||
</div>
|
||||
)}
|
||||
{escalation.source_module && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Quell-Modul</span>
|
||||
<p className="font-medium text-gray-800">{escalation.source_module}</p>
|
||||
</div>
|
||||
)}
|
||||
{escalation.resolved_at && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Geloest am</span>
|
||||
<p className="font-medium text-green-700">{formatDate(escalation.resolved_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit fields */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Bearbeiten</h3>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Zugewiesen an</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editAssignee}
|
||||
onChange={e => setEditAssignee(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Name oder Team"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Prioritaet</label>
|
||||
<select
|
||||
value={editPriority}
|
||||
onChange={e => setEditPriority(e.target.value as Escalation['priority'])}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Faelligkeit</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editDueDate}
|
||||
onChange={e => setEditDueDate(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={saving}
|
||||
className="w-full py-2 text-sm bg-gray-800 text-white rounded-lg hover:bg-gray-900 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Aenderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status transitions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Status-Aktionen</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{escalation.status === 'open' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('in_progress')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
In Bearbeitung nehmen
|
||||
</button>
|
||||
)}
|
||||
{escalation.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('escalated')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Eskalieren
|
||||
</button>
|
||||
)}
|
||||
{(escalation.status === 'escalated' || escalation.status === 'in_progress') && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('resolved')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Loesen
|
||||
</button>
|
||||
)}
|
||||
{escalation.status === 'resolved' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('closed')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
)}
|
||||
{escalation.status === 'closed' && (
|
||||
<p className="text-sm text-gray-400 text-center py-2">Eskalation ist geschlossen.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleDeleteEscalation}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Löschen...' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ESCALATION CARD
|
||||
// =============================================================================
|
||||
|
||||
interface CardProps {
|
||||
escalation: Escalation
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function EscalationCard({ escalation, onClick }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`bg-white rounded-xl border-2 p-6 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
escalation.priority === 'critical' ? 'border-red-300' :
|
||||
escalation.priority === 'high' ? 'border-orange-300' :
|
||||
escalation.status === 'resolved' || escalation.status === 'closed' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${priorityColors[escalation.priority]}`}>
|
||||
{priorityLabels[escalation.priority]}
|
||||
</span>
|
||||
{escalation.category && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-purple-100 text-purple-700">
|
||||
{categoryLabels[escalation.category] || escalation.category}
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[escalation.status]}`}>
|
||||
{statusLabels[escalation.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{escalation.title}</h3>
|
||||
{escalation.description && (
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{escalation.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-400 ml-3 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
{escalation.assignee && (
|
||||
<div>
|
||||
<span className="text-gray-500">Zugewiesen: </span>
|
||||
<span className="font-medium text-gray-700">{escalation.assignee}</span>
|
||||
</div>
|
||||
)}
|
||||
{escalation.due_date && (
|
||||
<div>
|
||||
<span className="text-gray-500">Frist: </span>
|
||||
<span className="font-medium text-gray-700">{formatDate(escalation.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">Erstellt: </span>
|
||||
<span className="font-medium text-gray-700">{formatDate(escalation.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400 font-mono">{escalation.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
import { useEscalations } from './_hooks/useEscalations'
|
||||
import { EscalationCard } from './_components/EscalationCard'
|
||||
import { EscalationCreateModal } from './_components/EscalationCreateModal'
|
||||
import { EscalationDetailDrawer } from './_components/EscalationDetailDrawer'
|
||||
import { Escalation } from './_components/types'
|
||||
|
||||
export default function EscalationsPage() {
|
||||
const { state } = useSDK()
|
||||
const [escalations, setEscalations] = useState<Escalation[]>([])
|
||||
const [stats, setStats] = useState<EscalationStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [selectedEscalation, setSelectedEscalation] = useState<Escalation | null>(null)
|
||||
|
||||
async function loadEscalations() {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '100' })
|
||||
if (filter !== 'all' && ['open', 'in_progress', 'escalated', 'resolved', 'closed'].includes(filter)) {
|
||||
params.set('status', filter)
|
||||
} else if (filter !== 'all' && ['low', 'medium', 'high', 'critical'].includes(filter)) {
|
||||
params.set('priority', filter)
|
||||
}
|
||||
const res = await fetch(`/api/sdk/v1/escalations?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setEscalations(data.items || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load escalations', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/escalations/stats')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true)
|
||||
await Promise.all([loadEscalations(), loadStats()])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter])
|
||||
const { escalations, stats, loading, loadAll } = useEscalations(filter)
|
||||
|
||||
const criticalCount = stats?.by_priority?.critical ?? 0
|
||||
const escalatedCount = stats?.by_status?.escalated ?? 0
|
||||
const openCount = stats?.by_status?.open ?? 0
|
||||
const activeCount = stats?.active ?? 0
|
||||
|
||||
const filteredEscalations = filter === 'all' || ['open', 'in_progress', 'escalated', 'resolved', 'closed'].includes(filter) || ['low', 'medium', 'high', 'critical'].includes(filter)
|
||||
? escalations
|
||||
: escalations
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['escalations']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="escalations"
|
||||
title={stepInfo.title}
|
||||
@@ -636,13 +41,10 @@ export default function EscalationsPage() {
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* 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 aktiv</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{loading ? '…' : activeCount}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{loading ? '…' : activeCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Kritisch</div>
|
||||
@@ -658,7 +60,6 @@ export default function EscalationsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Alert */}
|
||||
{criticalCount > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
@@ -673,7 +74,6 @@ export default function EscalationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{[
|
||||
@@ -699,12 +99,11 @@ export default function EscalationsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">Lade Eskalationen…</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredEscalations
|
||||
{escalations
|
||||
.sort((a, b) => {
|
||||
const priorityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const statusOrder: Record<string, number> = { escalated: 0, open: 1, in_progress: 2, resolved: 3, closed: 4 }
|
||||
@@ -720,7 +119,7 @@ export default function EscalationsPage() {
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredEscalations.length === 0 && (
|
||||
{escalations.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">
|
||||
@@ -734,7 +133,6 @@ export default function EscalationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{showCreateModal && (
|
||||
<EscalationCreateModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
|
||||
Reference in New Issue
Block a user