Files
breakpilot-compliance/admin-compliance/app/sdk/change-requests/page.tsx
Benjamin Admin 7fa0349fe4
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
fix(sdk): Portfolio/Workshop crash + Audit-LLM 403 + Change-Requests Sidebar
- 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>
2026-03-08 14:25:41 +01:00

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>
)
}