Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 29s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
- Remove non-existent setCurrentModule() calls from portfolio/workshop pages - Move change-requests from app/(sdk)/sdk/ to app/sdk/ for sidebar layout - Seed compliance_officer RBAC role for default admin user (audit-llm 403) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
|
|
interface ChangeRequest {
|
|
id: string
|
|
triggerType: string
|
|
targetDocumentType: string
|
|
targetDocumentId: string | null
|
|
targetSection: string | null
|
|
proposalTitle: string
|
|
proposalBody: string | null
|
|
proposedChanges: Record<string, unknown>
|
|
status: 'pending' | 'accepted' | 'rejected' | 'edited_and_accepted'
|
|
priority: 'low' | 'normal' | 'high' | 'critical'
|
|
decidedBy: string | null
|
|
decidedAt: string | null
|
|
rejectionReason: string | null
|
|
createdBy: string
|
|
createdAt: string
|
|
}
|
|
|
|
interface Stats {
|
|
total_pending: number
|
|
critical_count: number
|
|
total_accepted: number
|
|
total_rejected: number
|
|
by_document_type: Record<string, number>
|
|
}
|
|
|
|
const API_BASE = '/api/sdk/v1/compliance/change-requests'
|
|
|
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
|
dsfa: 'DSFA',
|
|
vvt: 'VVT',
|
|
tom: 'TOM',
|
|
loeschfristen: 'Löschfristen',
|
|
obligation: 'Pflichten',
|
|
}
|
|
|
|
const PRIORITY_COLORS: Record<string, string> = {
|
|
critical: 'bg-red-100 text-red-800',
|
|
high: 'bg-orange-100 text-orange-800',
|
|
normal: 'bg-blue-100 text-blue-800',
|
|
low: 'bg-gray-100 text-gray-700',
|
|
}
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
pending: 'bg-yellow-100 text-yellow-800',
|
|
accepted: 'bg-green-100 text-green-800',
|
|
rejected: 'bg-red-100 text-red-800',
|
|
edited_and_accepted: 'bg-emerald-100 text-emerald-800',
|
|
}
|
|
|
|
function snakeToCamel(obj: Record<string, unknown>): ChangeRequest {
|
|
return {
|
|
id: obj.id as string,
|
|
triggerType: obj.trigger_type as string,
|
|
targetDocumentType: obj.target_document_type as string,
|
|
targetDocumentId: obj.target_document_id as string | null,
|
|
targetSection: obj.target_section as string | null,
|
|
proposalTitle: obj.proposal_title as string,
|
|
proposalBody: obj.proposal_body as string | null,
|
|
proposedChanges: (obj.proposed_changes || {}) as Record<string, unknown>,
|
|
status: obj.status as ChangeRequest['status'],
|
|
priority: obj.priority as ChangeRequest['priority'],
|
|
decidedBy: obj.decided_by as string | null,
|
|
decidedAt: obj.decided_at as string | null,
|
|
rejectionReason: obj.rejection_reason as string | null,
|
|
createdBy: obj.created_by as string,
|
|
createdAt: obj.created_at as string,
|
|
}
|
|
}
|
|
|
|
export default function ChangeRequestsPage() {
|
|
const [requests, setRequests] = useState<ChangeRequest[]>([])
|
|
const [stats, setStats] = useState<Stats | null>(null)
|
|
const [filter, setFilter] = useState<string>('all')
|
|
const [statusFilter, setStatusFilter] = useState<string>('pending')
|
|
const [loading, setLoading] = useState(true)
|
|
const [actionModal, setActionModal] = useState<{ type: 'accept' | 'reject' | 'edit'; cr: ChangeRequest } | null>(null)
|
|
const [rejectReason, setRejectReason] = useState('')
|
|
const [editBody, setEditBody] = useState('')
|
|
|
|
const loadData = useCallback(async () => {
|
|
try {
|
|
const statsRes = await fetch(`${API_BASE}/stats`)
|
|
if (statsRes.ok) setStats(await statsRes.json())
|
|
|
|
let url = `${API_BASE}?status=${statusFilter}`
|
|
if (filter !== 'all') url += `&target_document_type=${filter}`
|
|
const res = await fetch(url)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setRequests((Array.isArray(data) ? data : []).map(snakeToCamel))
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load change requests:', e)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [filter, statusFilter])
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
const interval = setInterval(loadData, 60000)
|
|
return () => clearInterval(interval)
|
|
}, [loadData])
|
|
|
|
const handleAccept = async (cr: ChangeRequest) => {
|
|
const res = await fetch(`${API_BASE}/${cr.id}/accept`, { method: 'POST' })
|
|
if (res.ok) {
|
|
setActionModal(null)
|
|
loadData()
|
|
}
|
|
}
|
|
|
|
const handleReject = async (cr: ChangeRequest) => {
|
|
const res = await fetch(`${API_BASE}/${cr.id}/reject`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ rejection_reason: rejectReason }),
|
|
})
|
|
if (res.ok) {
|
|
setActionModal(null)
|
|
setRejectReason('')
|
|
loadData()
|
|
}
|
|
}
|
|
|
|
const handleEdit = async (cr: ChangeRequest) => {
|
|
const res = await fetch(`${API_BASE}/${cr.id}/edit`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ proposal_body: editBody }),
|
|
})
|
|
if (res.ok) {
|
|
setActionModal(null)
|
|
setEditBody('')
|
|
loadData()
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Änderungsanfrage wirklich löschen?')) return
|
|
const res = await fetch(`${API_BASE}/${id}`, { method: 'DELETE' })
|
|
if (res.ok) loadData()
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto">
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">Änderungsanfragen</h1>
|
|
|
|
{/* Stats Bar */}
|
|
{stats && (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-yellow-700">{stats.total_pending}</div>
|
|
<div className="text-sm text-yellow-600">Offen</div>
|
|
</div>
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-red-700">{stats.critical_count}</div>
|
|
<div className="text-sm text-red-600">Kritisch</div>
|
|
</div>
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-green-700">{stats.total_accepted}</div>
|
|
<div className="text-sm text-green-600">Angenommen</div>
|
|
</div>
|
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
|
<div className="text-2xl font-bold text-gray-700">{stats.total_rejected}</div>
|
|
<div className="text-sm text-gray-600">Abgelehnt</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap gap-2 mb-6">
|
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
|
{['pending', 'accepted', 'rejected'].map(s => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setStatusFilter(s)}
|
|
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
|
statusFilter === s ? 'bg-white shadow text-purple-700' : 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
{s === 'pending' ? 'Offen' : s === 'accepted' ? 'Angenommen' : 'Abgelehnt'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
|
{['all', 'dsfa', 'vvt', 'tom', 'loeschfristen', 'obligation'].map(t => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setFilter(t)}
|
|
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
|
|
filter === t ? 'bg-white shadow text-purple-700' : 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
{t === 'all' ? 'Alle' : DOC_TYPE_LABELS[t] || t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Request List */}
|
|
{loading ? (
|
|
<div className="text-center py-12 text-gray-500">Laden...</div>
|
|
) : requests.length === 0 ? (
|
|
<div className="text-center py-12 text-gray-400">
|
|
Keine Änderungsanfragen {statusFilter === 'pending' ? 'offen' : 'gefunden'}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{requests.map(cr => (
|
|
<div key={cr.id} className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-sm transition-shadow">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[cr.priority]}`}>
|
|
{cr.priority}
|
|
</span>
|
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[cr.status]}`}>
|
|
{cr.status}
|
|
</span>
|
|
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
|
{DOC_TYPE_LABELS[cr.targetDocumentType] || cr.targetDocumentType}
|
|
</span>
|
|
{cr.triggerType !== 'manual' && (
|
|
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600">
|
|
{cr.triggerType}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h3 className="font-medium text-gray-900">{cr.proposalTitle}</h3>
|
|
{cr.proposalBody && (
|
|
<p className="text-sm text-gray-600 mt-1 line-clamp-2">{cr.proposalBody}</p>
|
|
)}
|
|
<div className="text-xs text-gray-400 mt-2">
|
|
{new Date(cr.createdAt).toLocaleDateString('de-DE')} — {cr.createdBy}
|
|
{cr.rejectionReason && (
|
|
<span className="text-red-500 ml-2">Grund: {cr.rejectionReason}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{cr.status === 'pending' && (
|
|
<div className="flex gap-2 ml-4">
|
|
<button
|
|
onClick={() => { setActionModal({ type: 'accept', cr }) }}
|
|
className="px-3 py-1 bg-green-600 text-white rounded text-sm hover:bg-green-700"
|
|
>
|
|
Annehmen
|
|
</button>
|
|
<button
|
|
onClick={() => { setEditBody(cr.proposalBody || ''); setActionModal({ type: 'edit', cr }) }}
|
|
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
<button
|
|
onClick={() => { setRejectReason(''); setActionModal({ type: 'reject', cr }) }}
|
|
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300"
|
|
>
|
|
Ablehnen
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(cr.id)}
|
|
className="px-3 py-1 text-red-600 rounded text-sm hover:bg-red-50"
|
|
>
|
|
Löschen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Modal */}
|
|
{actionModal && (
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setActionModal(null)}>
|
|
<div className="bg-white rounded-xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
|
{actionModal.type === 'accept' && (
|
|
<>
|
|
<h3 className="text-lg font-semibold mb-4">Änderung annehmen?</h3>
|
|
<p className="text-sm text-gray-600 mb-4">{actionModal.cr.proposalTitle}</p>
|
|
{Object.keys(actionModal.cr.proposedChanges).length > 0 && (
|
|
<pre className="bg-gray-50 p-3 rounded text-xs mb-4 max-h-48 overflow-auto">
|
|
{JSON.stringify(actionModal.cr.proposedChanges, null, 2)}
|
|
</pre>
|
|
)}
|
|
<div className="flex justify-end gap-3">
|
|
<button onClick={() => setActionModal(null)} className="px-4 py-2 text-gray-600">Abbrechen</button>
|
|
<button onClick={() => handleAccept(actionModal.cr)} className="px-4 py-2 bg-green-600 text-white rounded-lg">Annehmen</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{actionModal.type === 'reject' && (
|
|
<>
|
|
<h3 className="text-lg font-semibold mb-4">Änderung ablehnen</h3>
|
|
<textarea
|
|
value={rejectReason}
|
|
onChange={e => setRejectReason(e.target.value)}
|
|
placeholder="Begründung..."
|
|
className="w-full border rounded-lg p-3 h-24 mb-4"
|
|
/>
|
|
<div className="flex justify-end gap-3">
|
|
<button onClick={() => setActionModal(null)} className="px-4 py-2 text-gray-600">Abbrechen</button>
|
|
<button
|
|
onClick={() => handleReject(actionModal.cr)}
|
|
disabled={!rejectReason.trim()}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg disabled:opacity-50"
|
|
>
|
|
Ablehnen
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{actionModal.type === 'edit' && (
|
|
<>
|
|
<h3 className="text-lg font-semibold mb-4">Vorschlag bearbeiten & annehmen</h3>
|
|
<textarea
|
|
value={editBody}
|
|
onChange={e => setEditBody(e.target.value)}
|
|
className="w-full border rounded-lg p-3 h-32 mb-4"
|
|
/>
|
|
<div className="flex justify-end gap-3">
|
|
<button onClick={() => setActionModal(null)} className="px-4 py-2 text-gray-600">Abbrechen</button>
|
|
<button
|
|
onClick={() => handleEdit(actionModal.cr)}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
|
>
|
|
Speichern & Annehmen
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|