feat(sdk): Multi-Tenancy, Versionierung, Change-Requests, Dokumentengenerierung (Phase 1-6)
All checks were successful
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) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
All checks were successful
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) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
6-Phasen-Implementation fuer cloud-faehiges, mandantenfaehiges Compliance SDK:
Phase 1: Multi-Tenancy Fix
- Shared tenant_utils.py Dependency (UUID-Validierung, kein "default" mehr)
- VVT tenant_id Column + tenant-scoped Queries
- DSFA/Vendor DEFAULT_TENANT_ID von "default" auf UUID migriert
- Migration 035
Phase 2: Stammdaten-Erweiterung
- Company Profile um JSONB-Felder erweitert (processing_systems, ai_systems, technical_contacts)
- Regulierungs-Flags (NIS2, AI Act, ISO 27001)
- GET /template-context Endpoint
- Migration 036
Phase 3: Dokument-Versionierung
- 5 Versions-Tabellen (DSFA, VVT, TOM, Loeschfristen, Obligations)
- Shared versioning_utils.py Helper
- /{id}/versions Endpoints auf allen 5 Dokumenttypen
- Migration 037
Phase 4: Change-Request System
- Zentrale CR-Inbox mit CRUD + Accept/Reject/Edit Workflow
- Regelbasierte CR-Engine (VVT DPIA → DSFA CR, Datenkategorien → Loeschfristen CR)
- Audit-Trail
- Migration 038
Phase 5: Dokumentengenerierung
- 5 Template-Generatoren (DSFA, VVT, TOM, Loeschfristen, Obligations)
- Preview + Apply Endpoints (erzeugt CRs, keine direkten Dokumente)
Phase 6: Frontend-Integration
- Change-Request Inbox Page mit Stats, Filtern, Modals
- VersionHistory Timeline-Komponente
- SDKSidebar CR-Badge (60s Polling)
- Company Profile: 2 neue Wizard-Steps + "Dokumente generieren" CTA
Docs: 5 neue MkDocs-Seiten, CLAUDE.md aktualisiert
Tests: 97 neue Tests (alle bestanden)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
345
admin-compliance/app/(sdk)/sdk/change-requests/page.tsx
Normal file
345
admin-compliance/app/(sdk)/sdk/change-requests/page.tsx
Normal file
@@ -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<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>
|
||||
)
|
||||
}
|
||||
@@ -35,9 +35,11 @@ const BASE_WIZARD_STEPS = [
|
||||
{ id: 3, name: 'Firmengroesse', description: 'Mitarbeiter und Umsatz' },
|
||||
{ id: 4, name: 'Standorte', description: 'Hauptsitz und Zielmaerkte' },
|
||||
{ id: 5, name: 'Datenschutz', description: 'Rollen und KI-Nutzung' },
|
||||
{ id: 6, name: 'Systeme & KI', description: 'IT-Systeme und KI-Katalog' },
|
||||
{ id: 7, name: 'Rechtlicher Rahmen', description: 'Regulierungen und Prüfzyklen' },
|
||||
]
|
||||
|
||||
const MACHINE_BUILDER_STEP = { id: 6, name: 'Produkt & Maschine', description: 'Software, KI und CE in Ihrem Produkt' }
|
||||
const MACHINE_BUILDER_STEP = { id: 8, name: 'Produkt & Maschine', description: 'Software, KI und CE in Ihrem Produkt' }
|
||||
|
||||
function getWizardSteps(industry: string) {
|
||||
if (isMachineBuilderIndustry(industry)) {
|
||||
@@ -542,7 +544,277 @@ function StepDataProtection({
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STEP 6: PRODUKT & MASCHINE (nur fuer Maschinenbauer)
|
||||
// STEP 6: SYSTEME & KI
|
||||
// =============================================================================
|
||||
|
||||
interface ProcessingSystem {
|
||||
name: string
|
||||
vendor: string
|
||||
hosting: string
|
||||
personal_data_categories: string[]
|
||||
}
|
||||
|
||||
interface AISystem {
|
||||
name: string
|
||||
purpose: string
|
||||
risk_category: string
|
||||
vendor: string
|
||||
has_human_oversight: boolean
|
||||
}
|
||||
|
||||
function StepSystemsAndAI({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile> & { processingSystems?: ProcessingSystem[]; aiSystems?: AISystem[] }
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const systems = (data as any).processingSystems || []
|
||||
const aiSystems = (data as any).aiSystems || []
|
||||
|
||||
const addSystem = () => {
|
||||
onChange({ processingSystems: [...systems, { name: '', vendor: '', hosting: 'cloud', personal_data_categories: [] }] })
|
||||
}
|
||||
const removeSystem = (i: number) => {
|
||||
onChange({ processingSystems: systems.filter((_: ProcessingSystem, idx: number) => idx !== i) })
|
||||
}
|
||||
const updateSystem = (i: number, updates: Partial<ProcessingSystem>) => {
|
||||
const updated = [...systems]
|
||||
updated[i] = { ...updated[i], ...updates }
|
||||
onChange({ processingSystems: updated })
|
||||
}
|
||||
|
||||
const addAISystem = () => {
|
||||
onChange({ aiSystems: [...aiSystems, { name: '', purpose: '', risk_category: 'limited', vendor: '', has_human_oversight: true }] })
|
||||
}
|
||||
const removeAISystem = (i: number) => {
|
||||
onChange({ aiSystems: aiSystems.filter((_: AISystem, idx: number) => idx !== i) })
|
||||
}
|
||||
const updateAISystem = (i: number, updates: Partial<AISystem>) => {
|
||||
const updated = [...aiSystems]
|
||||
updated[i] = { ...updated[i], ...updates }
|
||||
onChange({ aiSystems: updated })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Processing Systems */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">IT-Systeme mit personenbezogenen Daten</h3>
|
||||
<p className="text-xs text-gray-500">Systeme, die personenbezogene Daten verarbeiten (fuer VVT-Generierung)</p>
|
||||
</div>
|
||||
<button type="button" onClick={addSystem} className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200">
|
||||
+ System
|
||||
</button>
|
||||
</div>
|
||||
{systems.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-400 border-2 border-dashed rounded-lg">Noch keine Systeme hinzugefuegt</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{systems.map((sys: ProcessingSystem, i: number) => (
|
||||
<div key={i} className="border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-medium text-gray-400">System {i + 1}</span>
|
||||
<button type="button" onClick={() => removeSystem(i)} className="text-red-400 hover:text-red-600 text-xs">Entfernen</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={sys.name} onChange={e => updateSystem(i, { name: e.target.value })} placeholder="Name (z.B. SAP HR)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="text" value={sys.vendor} onChange={e => updateSystem(i, { vendor: e.target.value })} placeholder="Hersteller" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select value={sys.hosting} onChange={e => updateSystem(i, { hosting: e.target.value })} className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="on-premise">On-Premise</option>
|
||||
<option value="cloud">Cloud (EU)</option>
|
||||
<option value="us-cloud">Cloud (US)</option>
|
||||
<option value="hybrid">Hybrid</option>
|
||||
</select>
|
||||
<input type="text" value={sys.personal_data_categories.join(', ')} onChange={e => updateSystem(i, { personal_data_categories: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })} placeholder="Datenkategorien (kommagetrennt)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Systems */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">KI-Systeme</h3>
|
||||
<p className="text-xs text-gray-500">Strukturierter KI-Katalog fuer AI Act Compliance</p>
|
||||
</div>
|
||||
<button type="button" onClick={addAISystem} className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200">
|
||||
+ KI-System
|
||||
</button>
|
||||
</div>
|
||||
{aiSystems.length === 0 && (
|
||||
<div className="text-center py-6 text-gray-400 border-2 border-dashed rounded-lg">Noch keine KI-Systeme</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{aiSystems.map((ai: AISystem, i: number) => (
|
||||
<div key={i} className="border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-medium text-gray-400">KI-System {i + 1}</span>
|
||||
<button type="button" onClick={() => removeAISystem(i)} className="text-red-400 hover:text-red-600 text-xs">Entfernen</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={ai.name} onChange={e => updateAISystem(i, { name: e.target.value })} placeholder="Name (z.B. Chatbot)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="text" value={ai.vendor} onChange={e => updateAISystem(i, { vendor: e.target.value })} placeholder="Anbieter" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<input type="text" value={ai.purpose} onChange={e => updateAISystem(i, { purpose: e.target.value })} placeholder="Zweck (z.B. Kundensupport)" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select value={ai.risk_category} onChange={e => updateAISystem(i, { risk_category: e.target.value })} className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="minimal">Minimal Risk</option>
|
||||
<option value="limited">Limited Risk</option>
|
||||
<option value="high">High Risk</option>
|
||||
<option value="unacceptable">Unacceptable Risk</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 px-3 py-2">
|
||||
<input type="checkbox" checked={ai.has_human_oversight} onChange={e => updateAISystem(i, { has_human_oversight: e.target.checked })} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<span className="text-sm text-gray-700">Human Oversight</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STEP 7: RECHTLICHER RAHMEN
|
||||
// =============================================================================
|
||||
|
||||
function StepLegalFramework({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile> & { subjectToNis2?: boolean; subjectToAiAct?: boolean; subjectToIso27001?: boolean; supervisoryAuthority?: string; reviewCycleMonths?: number; technicalContacts?: { name: string; role: string; email: string }[] }
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const contacts = (data as any).technicalContacts || []
|
||||
|
||||
const addContact = () => {
|
||||
onChange({ technicalContacts: [...contacts, { name: '', role: '', email: '' }] })
|
||||
}
|
||||
const removeContact = (i: number) => {
|
||||
onChange({ technicalContacts: contacts.filter((_: { name: string; role: string; email: string }, idx: number) => idx !== i) })
|
||||
}
|
||||
const updateContact = (i: number, updates: Partial<{ name: string; role: string; email: string }>) => {
|
||||
const updated = [...contacts]
|
||||
updated[i] = { ...updated[i], ...updates }
|
||||
onChange({ technicalContacts: updated })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Regulatory Flags */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-4">Regulatorischer Rahmen</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'subjectToNis2', label: 'NIS2-Richtlinie', desc: 'Ihr Unternehmen fällt unter die NIS2-Richtlinie (Netzwerk- und Informationssicherheit)' },
|
||||
{ key: 'subjectToAiAct', label: 'EU AI Act', desc: 'Ihr Unternehmen setzt KI-Systeme ein, die unter den AI Act fallen' },
|
||||
{ key: 'subjectToIso27001', label: 'ISO 27001', desc: 'Ihr Unternehmen strebt ISO 27001 Zertifizierung an oder ist bereits zertifiziert' },
|
||||
].map(item => (
|
||||
<label
|
||||
key={item.key}
|
||||
className={`flex items-start gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
(data as any)[item.key] ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(data as any)[item.key] ?? false}
|
||||
onChange={e => onChange({ [item.key]: e.target.checked })}
|
||||
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supervisory Authority & Review Cycle */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Aufsichtsbehörde</label>
|
||||
<select
|
||||
value={(data as any).supervisoryAuthority || ''}
|
||||
onChange={e => onChange({ supervisoryAuthority: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
<option value="LfDI BW">LfDI Baden-Württemberg</option>
|
||||
<option value="BayLDA">BayLDA Bayern</option>
|
||||
<option value="BlnBDI">BlnBDI Berlin</option>
|
||||
<option value="LDA BB">LDA Brandenburg</option>
|
||||
<option value="LfDI HB">LfDI Bremen</option>
|
||||
<option value="HmbBfDI">HmbBfDI Hamburg</option>
|
||||
<option value="HBDI">HBDI Hessen</option>
|
||||
<option value="LfDI MV">LfDI Mecklenburg-Vorpommern</option>
|
||||
<option value="LfD NI">LfD Niedersachsen</option>
|
||||
<option value="LDI NRW">LDI NRW</option>
|
||||
<option value="LfDI RP">LfDI Rheinland-Pfalz</option>
|
||||
<option value="UDZ SL">UDZ Saarland</option>
|
||||
<option value="SächsDSB">Sächsischer DSB</option>
|
||||
<option value="LfD LSA">LfD Sachsen-Anhalt</option>
|
||||
<option value="ULD SH">ULD Schleswig-Holstein</option>
|
||||
<option value="TLfDI">TLfDI Thüringen</option>
|
||||
<option value="BfDI">BfDI (Bund)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Prüfzyklus (Monate)</label>
|
||||
<select
|
||||
value={(data as any).reviewCycleMonths || 12}
|
||||
onChange={e => onChange({ reviewCycleMonths: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value={3}>Vierteljährlich (3 Monate)</option>
|
||||
<option value={6}>Halbjährlich (6 Monate)</option>
|
||||
<option value={12}>Jährlich (12 Monate)</option>
|
||||
<option value={24}>Zweijährlich (24 Monate)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Contacts */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Technische Ansprechpartner</h3>
|
||||
<p className="text-xs text-gray-500">CISO, IT-Manager, DSB etc.</p>
|
||||
</div>
|
||||
<button type="button" onClick={addContact} className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200">
|
||||
+ Kontakt
|
||||
</button>
|
||||
</div>
|
||||
{contacts.length === 0 && (
|
||||
<div className="text-center py-4 text-gray-400 border-2 border-dashed rounded-lg text-sm">Noch keine Kontakte</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{contacts.map((c: { name: string; role: string; email: string }, i: number) => (
|
||||
<div key={i} className="flex gap-3 items-center">
|
||||
<input type="text" value={c.name} onChange={e => updateContact(i, { name: e.target.value })} placeholder="Name" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="text" value={c.role} onChange={e => updateContact(i, { role: e.target.value })} placeholder="Rolle (z.B. CISO)" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="email" value={c.email} onChange={e => updateContact(i, { email: e.target.value })} placeholder="E-Mail" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<button type="button" onClick={() => removeContact(i)} className="text-red-400 hover:text-red-600 text-sm">X</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STEP 8: PRODUKT & MASCHINE (nur fuer Maschinenbauer)
|
||||
// =============================================================================
|
||||
|
||||
const EMPTY_MACHINE_BUILDER: MachineBuilderProfile = {
|
||||
@@ -1090,6 +1362,71 @@ function CoverageAssessmentPanel({ profile }: { profile: Partial<CompanyProfile>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATE DOCUMENTS BUTTON
|
||||
// =============================================================================
|
||||
|
||||
const DOC_TYPES = [
|
||||
{ id: 'dsfa', label: 'DSFA', desc: 'Datenschutz-Folgenabschätzung' },
|
||||
{ id: 'vvt', label: 'VVT', desc: 'Verarbeitungsverzeichnis' },
|
||||
{ id: 'tom', label: 'TOM', desc: 'Technisch-Organisatorische Maßnahmen' },
|
||||
{ id: 'loeschfristen', label: 'Löschfristen', desc: 'Löschfristen-Katalog' },
|
||||
{ id: 'obligation', label: 'Pflichten', desc: 'Compliance-Pflichten' },
|
||||
]
|
||||
|
||||
function GenerateDocumentsButton() {
|
||||
const [generating, setGenerating] = useState<string | null>(null)
|
||||
const [results, setResults] = useState<Record<string, { ok: boolean; count: number }>>({})
|
||||
|
||||
const handleGenerate = async (docType: string) => {
|
||||
setGenerating(docType)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/generation/apply/${docType}`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResults(prev => ({ ...prev, [docType]: { ok: true, count: data.change_requests_created || 0 } }))
|
||||
} else {
|
||||
setResults(prev => ({ ...prev, [docType]: { ok: false, count: 0 } }))
|
||||
}
|
||||
} catch {
|
||||
setResults(prev => ({ ...prev, [docType]: { ok: false, count: 0 } }))
|
||||
} finally {
|
||||
setGenerating(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{DOC_TYPES.map(dt => (
|
||||
<div key={dt.id} className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{dt.label}</span>
|
||||
<span className="text-xs text-gray-500 ml-1">({dt.desc})</span>
|
||||
</div>
|
||||
{results[dt.id] ? (
|
||||
<span className={`text-xs px-2 py-1 rounded ${results[dt.id].ok ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{results[dt.id].ok ? `${results[dt.id].count} CR erstellt` : 'Fehler'}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleGenerate(dt.id)}
|
||||
disabled={generating !== null}
|
||||
className="px-3 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{generating === dt.id ? 'Generiere...' : 'Generieren'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(results).length > 0 && (
|
||||
<a href="/sdk/change-requests" className="block text-center text-sm text-purple-600 hover:text-purple-800 font-medium mt-3">
|
||||
Zur Änderungsanfragen-Inbox →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
@@ -1165,10 +1502,21 @@ export default function CompanyProfilePage() {
|
||||
dpoName: data.dpo_name || '',
|
||||
dpoEmail: data.dpo_email || '',
|
||||
isComplete: data.is_complete || false,
|
||||
}
|
||||
// Phase 2 extended fields
|
||||
processingSystems: data.processing_systems || [],
|
||||
aiSystems: data.ai_systems || [],
|
||||
technicalContacts: data.technical_contacts || [],
|
||||
subjectToNis2: data.subject_to_nis2 || false,
|
||||
subjectToAiAct: data.subject_to_ai_act || false,
|
||||
subjectToIso27001: data.subject_to_iso27001 || false,
|
||||
supervisoryAuthority: data.supervisory_authority || '',
|
||||
reviewCycleMonths: data.review_cycle_months || 12,
|
||||
repos: data.repos || [],
|
||||
documentSources: data.document_sources || [],
|
||||
} as any
|
||||
setFormData(backendProfile)
|
||||
if (backendProfile.isComplete) {
|
||||
setCurrentStep(5)
|
||||
setCurrentStep(7)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1181,7 +1529,7 @@ export default function CompanyProfilePage() {
|
||||
if (!cancelled && state.companyProfile) {
|
||||
setFormData(state.companyProfile)
|
||||
if (state.companyProfile.isComplete) {
|
||||
setCurrentStep(5)
|
||||
setCurrentStep(7)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1198,10 +1546,10 @@ export default function CompanyProfilePage() {
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < lastStep) {
|
||||
// Skip step 6 if not a machine builder
|
||||
// Skip step 8 if not a machine builder
|
||||
const nextStep = currentStep + 1
|
||||
if (nextStep === 6 && !showMachineBuilderStep) {
|
||||
// Complete profile (was step 5, last step for non-machine-builders)
|
||||
if (nextStep === 8 && !showMachineBuilderStep) {
|
||||
// Complete profile (was step 7, last step for non-machine-builders)
|
||||
completeAndSaveProfile()
|
||||
return
|
||||
}
|
||||
@@ -1250,6 +1598,17 @@ export default function CompanyProfilePage() {
|
||||
dpo_name: formData.dpoName || '',
|
||||
dpo_email: formData.dpoEmail || '',
|
||||
is_complete: true,
|
||||
// Phase 2 extended fields
|
||||
processing_systems: (formData as any).processingSystems || [],
|
||||
ai_systems: (formData as any).aiSystems || [],
|
||||
technical_contacts: (formData as any).technicalContacts || [],
|
||||
subject_to_nis2: (formData as any).subjectToNis2 || false,
|
||||
subject_to_ai_act: (formData as any).subjectToAiAct || false,
|
||||
subject_to_iso27001: (formData as any).subjectToIso27001 || false,
|
||||
supervisory_authority: (formData as any).supervisoryAuthority || '',
|
||||
review_cycle_months: (formData as any).reviewCycleMonths || 12,
|
||||
repos: (formData as any).repos || [],
|
||||
document_sources: (formData as any).documentSources || [],
|
||||
// Machine builder fields (if applicable)
|
||||
...(formData.machineBuilder ? {
|
||||
machine_builder: {
|
||||
@@ -1351,6 +1710,10 @@ export default function CompanyProfilePage() {
|
||||
case 5:
|
||||
return true
|
||||
case 6:
|
||||
return true // Systems & AI step is optional
|
||||
case 7:
|
||||
return true // Legal framework step is optional
|
||||
case 8:
|
||||
// Machine builder step: require at least product description
|
||||
return (formData.machineBuilder?.productDescription?.length || 0) > 0
|
||||
default:
|
||||
@@ -1358,7 +1721,7 @@ export default function CompanyProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const isLastStep = currentStep === lastStep || (currentStep === 5 && !showMachineBuilderStep)
|
||||
const isLastStep = currentStep === lastStep || (currentStep === 7 && !showMachineBuilderStep)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
@@ -1438,7 +1801,9 @@ export default function CompanyProfilePage() {
|
||||
{currentStep === 3 && <StepCompanySize data={formData} onChange={updateFormData} />}
|
||||
{currentStep === 4 && <StepLocations data={formData} onChange={updateFormData} />}
|
||||
{currentStep === 5 && <StepDataProtection data={formData} onChange={updateFormData} />}
|
||||
{currentStep === 6 && showMachineBuilderStep && <StepMachineBuilder data={formData} onChange={updateFormData} />}
|
||||
{currentStep === 6 && <StepSystemsAndAI data={formData} onChange={updateFormData} />}
|
||||
{currentStep === 7 && <StepLegalFramework data={formData} onChange={updateFormData} />}
|
||||
{currentStep === 8 && showMachineBuilderStep && <StepMachineBuilder data={formData} onChange={updateFormData} />}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-8 pt-6 border-t border-gray-200">
|
||||
@@ -1517,6 +1882,17 @@ export default function CompanyProfilePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Generate Documents CTA */}
|
||||
{formData.isComplete && (
|
||||
<div className="mt-6 bg-gradient-to-br from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-6">
|
||||
<h3 className="font-semibold text-purple-900 mb-2">Dokumente generieren</h3>
|
||||
<p className="text-sm text-purple-700 mb-4">
|
||||
Basierend auf Ihrem Profil können DSFA, VVT, TOM, Löschfristen und Pflichten automatisch als Entwürfe generiert werden.
|
||||
</p>
|
||||
<GenerateDocumentsButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Profile Button */}
|
||||
{formData.companyName && (
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -342,6 +342,25 @@ function CorpusStalenessInfo({ ragCorpusStatus }: { ragCorpusStatus: RAGCorpusSt
|
||||
export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const { state, packageCompletion, completionPercentage, getCheckpointStatus } = useSDK()
|
||||
const [pendingCRCount, setPendingCRCount] = React.useState(0)
|
||||
|
||||
// Poll pending change-request count every 60s
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
async function fetchCRCount() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/change-requests/stats')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (active) setPendingCRCount(data.total_pending || 0)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
fetchCRCount()
|
||||
const interval = setInterval(fetchCRCount, 60000)
|
||||
return () => { active = false; clearInterval(interval) }
|
||||
}, [])
|
||||
|
||||
const [expandedPackages, setExpandedPackages] = React.useState<Record<SDKPackageId, boolean>>({
|
||||
'vorbereitung': true,
|
||||
'analyse': false,
|
||||
@@ -695,6 +714,35 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
isActive={pathname === '/sdk/catalog-manager'}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<Link
|
||||
href="/sdk/change-requests"
|
||||
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
collapsed ? 'justify-center' : ''
|
||||
} ${
|
||||
pathname === '/sdk/change-requests'
|
||||
? 'bg-purple-100 text-purple-900 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
title={collapsed ? `Änderungsanfragen (${pendingCRCount})` : undefined}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
{!collapsed && (
|
||||
<span className="flex items-center gap-2">
|
||||
Änderungsanfragen
|
||||
{pendingCRCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-xs font-bold bg-red-500 text-white rounded-full min-w-[1.25rem] text-center">
|
||||
{pendingCRCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{collapsed && pendingCRCount > 0 && (
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
<AdditionalModuleItem
|
||||
href="https://macmini:3006"
|
||||
icon={
|
||||
|
||||
110
admin-compliance/components/sdk/VersionHistory.tsx
Normal file
110
admin-compliance/components/sdk/VersionHistory.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface Version {
|
||||
id: string
|
||||
versionNumber: number
|
||||
status: string
|
||||
changeSummary: string | null
|
||||
changedSections: string[]
|
||||
createdBy: string
|
||||
approvedBy: string | null
|
||||
approvedAt: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
interface VersionHistoryProps {
|
||||
documentType: 'dsfa' | 'vvt' | 'tom' | 'loeschfristen' | 'obligation'
|
||||
documentId: string
|
||||
apiPath: string
|
||||
}
|
||||
|
||||
export default function VersionHistory({ documentType, documentId, apiPath }: VersionHistoryProps) {
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/${apiPath}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setVersions(
|
||||
(Array.isArray(data) ? data : []).map((v: Record<string, unknown>) => ({
|
||||
id: v.id as string,
|
||||
versionNumber: v.version_number as number,
|
||||
status: v.status as string,
|
||||
changeSummary: v.change_summary as string | null,
|
||||
changedSections: (v.changed_sections || []) as string[],
|
||||
createdBy: v.created_by as string,
|
||||
approvedBy: v.approved_by as string | null,
|
||||
approvedAt: v.approved_at as string | null,
|
||||
createdAt: v.created_at as string | null,
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load versions:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
if (documentId) load()
|
||||
}, [documentId, apiPath])
|
||||
|
||||
if (loading) return null
|
||||
if (versions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 text-sm text-purple-600 hover:text-purple-800 font-medium"
|
||||
>
|
||||
<svg className={`w-4 h-4 transition-transform ${expanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
Versionen ({versions.length})
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 border-l-2 border-purple-200 pl-4 space-y-3">
|
||||
{versions.map(v => (
|
||||
<div key={v.id} className="relative">
|
||||
<div className="absolute -left-[1.35rem] top-1.5 w-2.5 h-2.5 rounded-full bg-purple-400 border-2 border-white" />
|
||||
<div className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">v{v.versionNumber}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${
|
||||
v.status === 'approved' ? 'bg-green-100 text-green-700' :
|
||||
v.status === 'draft' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{v.status}
|
||||
</span>
|
||||
{v.createdAt && (
|
||||
<span className="text-gray-400 text-xs">
|
||||
{new Date(v.createdAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{v.changeSummary && (
|
||||
<p className="text-gray-600 text-xs mt-0.5">{v.changeSummary}</p>
|
||||
)}
|
||||
{v.changedSections.length > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{v.changedSections.map((s, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 bg-purple-50 text-purple-600 rounded text-xs">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user