diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d320d9c..b18ba44 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -269,18 +269,55 @@ POST/GET /api/v1/compliance/evidence POST/GET /api/v1/dsr/requests POST/GET /api/v1/gdpr/exports POST/GET /api/v1/consent/admin + +# Stammdaten, Versionierung & Change-Requests (Phase 1-6, 2026-03-07) +GET/POST/DELETE /api/compliance/company-profile +GET /api/compliance/company-profile/template-context +GET /api/compliance/change-requests +GET /api/compliance/change-requests/stats +POST /api/compliance/change-requests/{id}/accept +POST /api/compliance/change-requests/{id}/reject +POST /api/compliance/change-requests/{id}/edit +GET /api/compliance/generation/preview/{doc_type} +POST /api/compliance/generation/apply/{doc_type} +GET /api/compliance/{doc}/{id}/versions ``` +### Multi-Tenancy +- Shared Dependency: `compliance/api/tenant_utils.py` (`get_tenant_id()`) +- UUID-Format, kein `"default"` mehr +- Header `X-Tenant-ID` > Query `tenant_id` > ENV-Fallback + +### Migrations (035-038) +| Nr | Datei | Beschreibung | +|----|-------|--------------| +| 035 | `migrations/035_vvt_tenant_isolation.sql` | VVT tenant_id + DSFA/Vendor default→UUID | +| 036 | `migrations/036_company_profile_extend.sql` | Stammdaten JSONB + Regulierungs-Flags | +| 037 | `migrations/037_document_versions.sql` | 5 Versions-Tabellen + current_version | +| 038 | `migrations/038_change_requests.sql` | Change-Requests + Audit-Log | + +### Neue Backend-Module +| Datei | Beschreibung | +|-------|--------------| +| `compliance/api/tenant_utils.py` | Shared Tenant-ID Dependency | +| `compliance/api/versioning_utils.py` | Shared Versioning Helper | +| `compliance/api/change_request_routes.py` | CR CRUD + Accept/Reject/Edit | +| `compliance/api/change_request_engine.py` | Regelbasierte CR-Generierung | +| `compliance/api/generation_routes.py` | Dokumentengenerierung aus Stammdaten | +| `compliance/api/document_templates/` | 5 Template-Generatoren (DSFA, VVT, TOM, etc.) | + --- ## Wichtige Dateien (Referenz) | Datei | Beschreibung | |-------|--------------| -| `admin-compliance/app/(sdk)/` | Alle 37 SDK-Routes | -| `admin-compliance/components/sdk/SDKSidebar.tsx` | SDK Navigation | +| `admin-compliance/app/(sdk)/` | Alle 37+ SDK-Routes | +| `admin-compliance/app/(sdk)/sdk/change-requests/page.tsx` | Change-Request Inbox | +| `admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx` | SDK Navigation (mit CR-Badge) | +| `admin-compliance/components/sdk/VersionHistory.tsx` | Versions-Timeline-Komponente | | `admin-compliance/components/sdk/CommandBar.tsx` | Command Palette | | `admin-compliance/lib/sdk/context.tsx` | SDK State (Provider) | -| `backend-compliance/compliance/` | Haupt-Package (40 Dateien) | +| `backend-compliance/compliance/` | Haupt-Package (50+ Dateien) | | `ai-compliance-sdk/` | KI-Compliance Analyse | | `developer-portal/` | API-Dokumentation | diff --git a/admin-compliance/app/(sdk)/sdk/change-requests/page.tsx b/admin-compliance/app/(sdk)/sdk/change-requests/page.tsx new file mode 100644 index 0000000..4ada0b4 --- /dev/null +++ b/admin-compliance/app/(sdk)/sdk/change-requests/page.tsx @@ -0,0 +1,345 @@ +'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 + 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 +} + +const API_BASE = '/api/sdk/v1/compliance/change-requests' + +const DOC_TYPE_LABELS: Record = { + dsfa: 'DSFA', + vvt: 'VVT', + tom: 'TOM', + loeschfristen: 'Löschfristen', + obligation: 'Pflichten', +} + +const PRIORITY_COLORS: Record = { + 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 = { + 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): 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, + 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([]) + const [stats, setStats] = useState(null) + const [filter, setFilter] = useState('all') + const [statusFilter, setStatusFilter] = useState('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 ( +
+

Änderungsanfragen

+ + {/* Stats Bar */} + {stats && ( +
+
+
{stats.total_pending}
+
Offen
+
+
+
{stats.critical_count}
+
Kritisch
+
+
+
{stats.total_accepted}
+
Angenommen
+
+
+
{stats.total_rejected}
+
Abgelehnt
+
+
+ )} + + {/* Filters */} +
+
+ {['pending', 'accepted', 'rejected'].map(s => ( + + ))} +
+
+ {['all', 'dsfa', 'vvt', 'tom', 'loeschfristen', 'obligation'].map(t => ( + + ))} +
+
+ + {/* Request List */} + {loading ? ( +
Laden...
+ ) : requests.length === 0 ? ( +
+ Keine Änderungsanfragen {statusFilter === 'pending' ? 'offen' : 'gefunden'} +
+ ) : ( +
+ {requests.map(cr => ( +
+
+
+
+ + {cr.priority} + + + {cr.status} + + + {DOC_TYPE_LABELS[cr.targetDocumentType] || cr.targetDocumentType} + + {cr.triggerType !== 'manual' && ( + + {cr.triggerType} + + )} +
+

{cr.proposalTitle}

+ {cr.proposalBody && ( +

{cr.proposalBody}

+ )} +
+ {new Date(cr.createdAt).toLocaleDateString('de-DE')} — {cr.createdBy} + {cr.rejectionReason && ( + Grund: {cr.rejectionReason} + )} +
+
+ + {cr.status === 'pending' && ( +
+ + + + +
+ )} +
+
+ ))} +
+ )} + + {/* Action Modal */} + {actionModal && ( +
setActionModal(null)}> +
e.stopPropagation()}> + {actionModal.type === 'accept' && ( + <> +

Änderung annehmen?

+

{actionModal.cr.proposalTitle}

+ {Object.keys(actionModal.cr.proposedChanges).length > 0 && ( +
+                    {JSON.stringify(actionModal.cr.proposedChanges, null, 2)}
+                  
+ )} +
+ + +
+ + )} + {actionModal.type === 'reject' && ( + <> +

Änderung ablehnen

+