fix(admin-v2): Restore complete admin-v2 application
The admin-v2 application was incomplete in the repository. This commit restores all missing components: - Admin pages (76 pages): dashboard, ai, compliance, dsgvo, education, infrastructure, communication, development, onboarding, rbac - SDK pages (45 pages): tom, dsfa, vvt, loeschfristen, einwilligungen, vendor-compliance, tom-generator, dsr, and more - Developer portal (25 pages): API docs, SDK guides, frameworks - All components, lib files, hooks, and types - Updated package.json with all dependencies The issue was caused by incomplete initial repository state - the full admin-v2 codebase existed in backend/admin-v2 and docs-src/admin-v2 but was never fully synced to the main admin-v2 directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
864
admin-v2/app/(admin)/dsgvo/escalations/page.tsx
Normal file
864
admin-v2/app/(admin)/dsgvo/escalations/page.tsx
Normal file
@@ -0,0 +1,864 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Escalation Queue Page
|
||||
*
|
||||
* DSB Review & Approval Workflow for UCCA Assessments
|
||||
* Implements E0-E3 escalation levels with SLA tracking
|
||||
*
|
||||
* API: /sdk/v1/ucca/escalations
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
|
||||
// Types
|
||||
interface Escalation {
|
||||
id: string
|
||||
tenant_id: string
|
||||
assessment_id: string
|
||||
escalation_level: 'E0' | 'E1' | 'E2' | 'E3'
|
||||
escalation_reason: string
|
||||
assigned_to?: string
|
||||
assigned_role?: string
|
||||
assigned_at?: string
|
||||
status: 'pending' | 'assigned' | 'in_review' | 'approved' | 'rejected' | 'returned'
|
||||
reviewer_id?: string
|
||||
reviewer_notes?: string
|
||||
reviewed_at?: string
|
||||
decision?: 'approve' | 'reject' | 'modify' | 'escalate'
|
||||
decision_notes?: string
|
||||
decision_at?: string
|
||||
conditions?: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
due_date?: string
|
||||
notification_sent: boolean
|
||||
// Joined fields
|
||||
assessment_title?: string
|
||||
assessment_feasibility?: string
|
||||
assessment_risk_score?: number
|
||||
assessment_domain?: string
|
||||
}
|
||||
|
||||
interface EscalationHistory {
|
||||
id: string
|
||||
escalation_id: string
|
||||
action: string
|
||||
old_status?: string
|
||||
new_status?: string
|
||||
old_level?: string
|
||||
new_level?: string
|
||||
actor_id: string
|
||||
actor_role?: string
|
||||
notes?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface EscalationStats {
|
||||
total_pending: number
|
||||
total_in_review: number
|
||||
total_approved: number
|
||||
total_rejected: number
|
||||
by_level: Record<string, number>
|
||||
overdue_sla: number
|
||||
approaching_sla: number
|
||||
avg_resolution_hours: number
|
||||
}
|
||||
|
||||
interface DSBPoolMember {
|
||||
id: string
|
||||
tenant_id: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_email: string
|
||||
role: string
|
||||
is_active: boolean
|
||||
max_concurrent_reviews: number
|
||||
current_reviews: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Constants
|
||||
const LEVEL_CONFIG = {
|
||||
E0: { label: 'Auto-Approve', color: 'bg-green-100 text-green-800', description: 'Automatische Freigabe' },
|
||||
E1: { label: 'Team-Lead', color: 'bg-blue-100 text-blue-800', description: 'Team-Lead Review erforderlich' },
|
||||
E2: { label: 'DSB', color: 'bg-yellow-100 text-yellow-800', description: 'DSB-Konsultation erforderlich' },
|
||||
E3: { label: 'DSB + Legal', color: 'bg-red-100 text-red-800', description: 'DSB + Rechtsabteilung erforderlich' },
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-800' },
|
||||
assigned: { label: 'Zugewiesen', color: 'bg-blue-100 text-blue-800' },
|
||||
in_review: { label: 'In Prüfung', color: 'bg-yellow-100 text-yellow-800' },
|
||||
approved: { label: 'Genehmigt', color: 'bg-green-100 text-green-800' },
|
||||
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-800' },
|
||||
returned: { label: 'Zurückgegeben', color: 'bg-orange-100 text-orange-800' },
|
||||
}
|
||||
|
||||
export default function EscalationsPage() {
|
||||
const [escalations, setEscalations] = useState<Escalation[]>([])
|
||||
const [stats, setStats] = useState<EscalationStats | null>(null)
|
||||
const [dsbPool, setDsbPool] = useState<DSBPoolMember[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState<string>('pending')
|
||||
const [levelFilter, setLevelFilter] = useState<string>('')
|
||||
const [myReviewsOnly, setMyReviewsOnly] = useState(false)
|
||||
|
||||
// Selected escalation for detail view
|
||||
const [selectedEscalation, setSelectedEscalation] = useState<Escalation | null>(null)
|
||||
const [escalationHistory, setEscalationHistory] = useState<EscalationHistory[]>([])
|
||||
|
||||
// Decision modal
|
||||
const [showDecisionModal, setShowDecisionModal] = useState(false)
|
||||
const [decisionForm, setDecisionForm] = useState({
|
||||
decision: 'approve' as 'approve' | 'reject' | 'modify' | 'escalate',
|
||||
decision_notes: '',
|
||||
conditions: [] as string[],
|
||||
})
|
||||
const [newCondition, setNewCondition] = useState('')
|
||||
|
||||
// DSB Pool modal
|
||||
const [showDSBPoolModal, setShowDSBPoolModal] = useState(false)
|
||||
const [newMember, setNewMember] = useState({
|
||||
user_id: '',
|
||||
user_name: '',
|
||||
user_email: '',
|
||||
role: 'dsb',
|
||||
max_concurrent_reviews: 10,
|
||||
})
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
loadEscalations()
|
||||
loadStats()
|
||||
loadDSBPool()
|
||||
}, [statusFilter, levelFilter, myReviewsOnly])
|
||||
|
||||
async function loadEscalations() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (statusFilter && statusFilter !== 'all') params.append('status', statusFilter)
|
||||
if (levelFilter) params.append('level', levelFilter)
|
||||
if (myReviewsOnly) params.append('my_reviews', 'true')
|
||||
|
||||
const res = await fetch(`/sdk/v1/ucca/escalations?${params}`, {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setEscalations(data.escalations || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load escalations:', err)
|
||||
setError('Fehler beim Laden der Eskalationen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/sdk/v1/ucca/escalations/stats', {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDSBPool() {
|
||||
try {
|
||||
const res = await fetch('/sdk/v1/ucca/dsb-pool', {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setDsbPool(data.members || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load DSB pool:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEscalationDetail(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/sdk/v1/ucca/escalations/${id}`, {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setSelectedEscalation(data.escalation)
|
||||
setEscalationHistory(data.history || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load escalation detail:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function startReview(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/sdk/v1/ucca/escalations/${id}/review`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
loadEscalations()
|
||||
if (selectedEscalation?.id === id) {
|
||||
loadEscalationDetail(id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start review:', err)
|
||||
alert('Fehler beim Starten der Prüfung')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitDecision() {
|
||||
if (!selectedEscalation) return
|
||||
try {
|
||||
const res = await fetch(`/sdk/v1/ucca/escalations/${selectedEscalation.id}/decide`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
},
|
||||
body: JSON.stringify(decisionForm)
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setShowDecisionModal(false)
|
||||
setDecisionForm({ decision: 'approve', decision_notes: '', conditions: [] })
|
||||
loadEscalations()
|
||||
loadStats()
|
||||
setSelectedEscalation(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to submit decision:', err)
|
||||
alert('Fehler beim Speichern der Entscheidung')
|
||||
}
|
||||
}
|
||||
|
||||
async function assignEscalation(escalationId: string, userId: string) {
|
||||
try {
|
||||
const res = await fetch(`/sdk/v1/ucca/escalations/${escalationId}/assign`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
},
|
||||
body: JSON.stringify({ assigned_to: userId })
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
loadEscalations()
|
||||
if (selectedEscalation?.id === escalationId) {
|
||||
loadEscalationDetail(escalationId)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to assign escalation:', err)
|
||||
alert('Fehler bei der Zuweisung')
|
||||
}
|
||||
}
|
||||
|
||||
async function addDSBPoolMember() {
|
||||
try {
|
||||
const res = await fetch('/sdk/v1/ucca/dsb-pool', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
},
|
||||
body: JSON.stringify(newMember)
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
setShowDSBPoolModal(false)
|
||||
setNewMember({ user_id: '', user_name: '', user_email: '', role: 'dsb', max_concurrent_reviews: 10 })
|
||||
loadDSBPool()
|
||||
} catch (err) {
|
||||
console.error('Failed to add DSB pool member:', err)
|
||||
alert('Fehler beim Hinzufügen')
|
||||
}
|
||||
}
|
||||
|
||||
function addCondition() {
|
||||
if (newCondition.trim()) {
|
||||
setDecisionForm(prev => ({
|
||||
...prev,
|
||||
conditions: [...prev.conditions, newCondition.trim()]
|
||||
}))
|
||||
setNewCondition('')
|
||||
}
|
||||
}
|
||||
|
||||
function removeCondition(index: number) {
|
||||
setDecisionForm(prev => ({
|
||||
...prev,
|
||||
conditions: prev.conditions.filter((_, i) => i !== index)
|
||||
}))
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function isOverdue(dueDate?: string) {
|
||||
if (!dueDate) return false
|
||||
return new Date(dueDate) < new Date()
|
||||
}
|
||||
|
||||
function getTimeRemaining(dueDate?: string) {
|
||||
if (!dueDate) return null
|
||||
const now = new Date()
|
||||
const due = new Date(dueDate)
|
||||
const diff = due.getTime() - now.getTime()
|
||||
if (diff < 0) return 'Überfällig'
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
if (hours < 24) return `${hours}h verbleibend`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d verbleibend`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Eskalations-Queue</h1>
|
||||
<p className="text-gray-600 mt-1">DSB Review & Freigabe-Workflow für UCCA Assessments</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDSBPoolModal(true)}
|
||||
className="px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors"
|
||||
>
|
||||
DSB-Pool verwalten
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<PagePurpose
|
||||
title="Eskalations-Queue"
|
||||
purpose="Verwaltung von Eskalationen aus dem Advisory Board. DSB und Team-Leads prüfen risikoreiche Use-Cases (E1-E3) und erteilen Freigaben oder Ablehnungen mit Auflagen."
|
||||
audience={['DSB', 'Team-Leads', 'Legal']}
|
||||
gdprArticles={['Art. 5', 'Art. 22', 'Art. 35', 'Art. 36']}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="text-2xl font-bold text-gray-900">{stats.total_pending}</div>
|
||||
<div className="text-sm text-gray-600">Ausstehend</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="text-2xl font-bold text-yellow-600">{stats.total_in_review}</div>
|
||||
<div className="text-sm text-gray-600">In Prüfung</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.total_approved}</div>
|
||||
<div className="text-sm text-gray-600">Genehmigt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="text-2xl font-bold text-red-600">{stats.total_rejected}</div>
|
||||
<div className="text-sm text-gray-600">Abgelehnt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="text-2xl font-bold text-red-600">{stats.overdue_sla}</div>
|
||||
<div className="text-sm text-gray-600">SLA überschritten</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">{stats.approaching_sla}</div>
|
||||
<div className="text-sm text-gray-600">SLA gefährdet</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Level Distribution */}
|
||||
{stats && stats.by_level && (
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Verteilung nach Eskalationsstufe</h3>
|
||||
<div className="flex gap-4">
|
||||
{Object.entries(LEVEL_CONFIG).map(([level, config]) => (
|
||||
<div key={level} className="flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${config.color}`}>
|
||||
{level}
|
||||
</span>
|
||||
<span className="text-gray-600">{stats.by_level[level] || 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg border p-4">
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="border rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">Alle</option>
|
||||
<option value="pending">Ausstehend</option>
|
||||
<option value="assigned">Zugewiesen</option>
|
||||
<option value="in_review">In Prüfung</option>
|
||||
<option value="approved">Genehmigt</option>
|
||||
<option value="rejected">Abgelehnt</option>
|
||||
<option value="returned">Zurückgegeben</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Level</label>
|
||||
<select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
className="border rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
<option value="E1">E1 - Team-Lead</option>
|
||||
<option value="E2">E2 - DSB</option>
|
||||
<option value="E3">E3 - DSB + Legal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="myReviews"
|
||||
checked={myReviewsOnly}
|
||||
onChange={(e) => setMyReviewsOnly(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="myReviews" className="text-sm text-gray-700">Nur meine Zuweisungen</label>
|
||||
</div>
|
||||
<div className="ml-auto pt-6">
|
||||
<button
|
||||
onClick={loadEscalations}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Escalation List */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-lg border p-8 text-center text-gray-500">
|
||||
Laden...
|
||||
</div>
|
||||
) : escalations.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-8 text-center text-gray-500">
|
||||
Keine Eskalationen gefunden
|
||||
</div>
|
||||
) : (
|
||||
escalations.map((esc) => (
|
||||
<div
|
||||
key={esc.id}
|
||||
onClick={() => loadEscalationDetail(esc.id)}
|
||||
className={`bg-white rounded-lg border p-4 cursor-pointer hover:border-violet-300 transition-colors ${
|
||||
selectedEscalation?.id === esc.id ? 'border-violet-500 ring-2 ring-violet-200' : ''
|
||||
} ${isOverdue(esc.due_date) && esc.status !== 'approved' && esc.status !== 'rejected' ? 'border-red-300 bg-red-50' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${LEVEL_CONFIG[esc.escalation_level].color}`}>
|
||||
{esc.escalation_level}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${STATUS_CONFIG[esc.status].color}`}>
|
||||
{STATUS_CONFIG[esc.status].label}
|
||||
</span>
|
||||
{esc.due_date && (
|
||||
<span className={`text-xs ${isOverdue(esc.due_date) ? 'text-red-600 font-medium' : 'text-gray-500'}`}>
|
||||
{getTimeRemaining(esc.due_date)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900">
|
||||
{esc.assessment_title || `Assessment ${esc.assessment_id.slice(0, 8)}`}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{esc.escalation_reason}</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<span>Erstellt: {formatDate(esc.created_at)}</span>
|
||||
{esc.assessment_risk_score !== undefined && (
|
||||
<span>Risk: {esc.assessment_risk_score}/100</span>
|
||||
)}
|
||||
{esc.assessment_domain && (
|
||||
<span>Domain: {esc.assessment_domain}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{esc.status === 'pending' && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
startReview(esc.id)
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-violet-100 text-violet-700 rounded hover:bg-violet-200 transition-colors"
|
||||
>
|
||||
Review starten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
<div className="lg:col-span-1">
|
||||
{selectedEscalation ? (
|
||||
<div className="bg-white rounded-lg border p-4 sticky top-6 space-y-4">
|
||||
<h3 className="font-semibold text-gray-900">Detail</h3>
|
||||
|
||||
{/* Status & Level */}
|
||||
<div className="flex gap-2">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${LEVEL_CONFIG[selectedEscalation.escalation_level].color}`}>
|
||||
{selectedEscalation.escalation_level} - {LEVEL_CONFIG[selectedEscalation.escalation_level].label}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${STATUS_CONFIG[selectedEscalation.status].color}`}>
|
||||
{STATUS_CONFIG[selectedEscalation.status].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">Grund</div>
|
||||
<div className="text-sm text-gray-600 mt-1">{selectedEscalation.escalation_reason}</div>
|
||||
</div>
|
||||
|
||||
{/* SLA */}
|
||||
{selectedEscalation.due_date && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">SLA Deadline</div>
|
||||
<div className={`text-sm mt-1 ${isOverdue(selectedEscalation.due_date) ? 'text-red-600 font-medium' : 'text-gray-600'}`}>
|
||||
{formatDate(selectedEscalation.due_date)}
|
||||
{isOverdue(selectedEscalation.due_date) && ' (Überfällig!)'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignment */}
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">Zugewiesen an</div>
|
||||
{selectedEscalation.assigned_to ? (
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{selectedEscalation.assigned_role || 'Unbekannt'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
assignEscalation(selectedEscalation.id, e.target.value)
|
||||
}
|
||||
}}
|
||||
className="w-full border rounded px-2 py-1 text-sm"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="">Reviewer auswählen...</option>
|
||||
{dsbPool.filter(m => m.is_active).map(member => (
|
||||
<option key={member.user_id} value={member.user_id}>
|
||||
{member.user_name} ({member.role}) - {member.current_reviews}/{member.max_concurrent_reviews}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Decision */}
|
||||
{selectedEscalation.decision && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">Entscheidung</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{selectedEscalation.decision === 'approve' && '✅ Genehmigt'}
|
||||
{selectedEscalation.decision === 'reject' && '❌ Abgelehnt'}
|
||||
{selectedEscalation.decision === 'modify' && '🔄 Änderungen erforderlich'}
|
||||
{selectedEscalation.decision === 'escalate' && '⬆️ Eskaliert'}
|
||||
</div>
|
||||
{selectedEscalation.decision_notes && (
|
||||
<div className="text-sm text-gray-500 mt-1">{selectedEscalation.decision_notes}</div>
|
||||
)}
|
||||
{selectedEscalation.conditions && selectedEscalation.conditions.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs font-medium text-gray-700">Auflagen:</div>
|
||||
<ul className="list-disc list-inside text-xs text-gray-600">
|
||||
{selectedEscalation.conditions.map((c, i) => (
|
||||
<li key={i}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{(selectedEscalation.status === 'assigned' || selectedEscalation.status === 'in_review') && (
|
||||
<div className="pt-4 border-t">
|
||||
<button
|
||||
onClick={() => setShowDecisionModal(true)}
|
||||
className="w-full px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors"
|
||||
>
|
||||
Entscheidung treffen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{escalationHistory.length > 0 && (
|
||||
<div className="pt-4 border-t">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">Verlauf</div>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{escalationHistory.map((h) => (
|
||||
<div key={h.id} className="text-xs border-l-2 border-gray-200 pl-2">
|
||||
<div className="text-gray-900">{h.action}</div>
|
||||
{h.notes && <div className="text-gray-500">{h.notes}</div>}
|
||||
<div className="text-gray-400">{formatDate(h.created_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link to Assessment */}
|
||||
<div className="pt-4 border-t">
|
||||
<a
|
||||
href={`/dsgvo/advisory-board?assessment=${selectedEscalation.assessment_id}`}
|
||||
className="text-sm text-violet-600 hover:text-violet-800"
|
||||
>
|
||||
→ Assessment anzeigen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-50 rounded-lg border border-dashed p-8 text-center text-gray-500">
|
||||
Wählen Sie eine Eskalation aus, um Details zu sehen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decision Modal */}
|
||||
{showDecisionModal && selectedEscalation && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-lg mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Entscheidung für {selectedEscalation.escalation_level}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entscheidung</label>
|
||||
<select
|
||||
value={decisionForm.decision}
|
||||
onChange={(e) => setDecisionForm(prev => ({ ...prev, decision: e.target.value as any }))}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="approve">✅ Genehmigen</option>
|
||||
<option value="reject">❌ Ablehnen</option>
|
||||
<option value="modify">🔄 Änderungen erforderlich</option>
|
||||
<option value="escalate">⬆️ Weiter eskalieren</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Begründung</label>
|
||||
<textarea
|
||||
value={decisionForm.decision_notes}
|
||||
onChange={(e) => setDecisionForm(prev => ({ ...prev, decision_notes: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full border rounded-lg px-3 py-2"
|
||||
placeholder="Begründung für die Entscheidung..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(decisionForm.decision === 'approve' || decisionForm.decision === 'modify') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Auflagen (optional)</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCondition}
|
||||
onChange={(e) => setNewCondition(e.target.value)}
|
||||
placeholder="Neue Auflage eingeben..."
|
||||
className="flex-1 border rounded-lg px-3 py-2 text-sm"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCondition())}
|
||||
/>
|
||||
<button
|
||||
onClick={addCondition}
|
||||
className="px-3 py-2 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{decisionForm.conditions.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{decisionForm.conditions.map((c, i) => (
|
||||
<li key={i} className="flex items-center justify-between bg-gray-50 px-2 py-1 rounded text-sm">
|
||||
<span>{c}</span>
|
||||
<button
|
||||
onClick={() => removeCondition(i)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => setShowDecisionModal(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={submitDecision}
|
||||
className="px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700"
|
||||
>
|
||||
Entscheidung speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DSB Pool Modal */}
|
||||
{showDSBPoolModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4">DSB-Pool verwalten</h3>
|
||||
|
||||
{/* Current Members */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Aktuelle Mitglieder</h4>
|
||||
{dsbPool.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Keine Mitglieder im Pool</p>
|
||||
) : (
|
||||
<div className="border rounded-lg divide-y">
|
||||
{dsbPool.map(member => (
|
||||
<div key={member.id} className="p-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{member.user_name}</div>
|
||||
<div className="text-sm text-gray-500">{member.user_email}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-2 py-1 rounded text-xs ${
|
||||
member.role === 'dsb' ? 'bg-violet-100 text-violet-800' :
|
||||
member.role === 'team_lead' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{member.role}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{member.current_reviews}/{member.max_concurrent_reviews} Reviews
|
||||
</span>
|
||||
<span className={`w-2 h-2 rounded-full ${member.is_active ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add New Member */}
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Neues Mitglied hinzufügen</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newMember.user_name}
|
||||
onChange={(e) => setNewMember(prev => ({ ...prev, user_name: e.target.value }))}
|
||||
placeholder="Name"
|
||||
className="border rounded-lg px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={newMember.user_email}
|
||||
onChange={(e) => setNewMember(prev => ({ ...prev, user_email: e.target.value }))}
|
||||
placeholder="E-Mail"
|
||||
className="border rounded-lg px-3 py-2"
|
||||
/>
|
||||
<select
|
||||
value={newMember.role}
|
||||
onChange={(e) => setNewMember(prev => ({ ...prev, role: e.target.value }))}
|
||||
className="border rounded-lg px-3 py-2"
|
||||
>
|
||||
<option value="dsb">DSB</option>
|
||||
<option value="deputy_dsb">Stellv. DSB</option>
|
||||
<option value="team_lead">Team-Lead</option>
|
||||
<option value="legal">Legal</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={newMember.max_concurrent_reviews}
|
||||
onChange={(e) => setNewMember(prev => ({ ...prev, max_concurrent_reviews: parseInt(e.target.value) || 10 }))}
|
||||
placeholder="Max Reviews"
|
||||
className="border rounded-lg px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={addDSBPoolMember}
|
||||
disabled={!newMember.user_name || !newMember.user_email}
|
||||
className="mt-4 px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
onClick={() => setShowDSBPoolModal(false)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user