Files
breakpilot-compliance/admin-compliance/app/(sdk)/sdk/loeschfristen/page.tsx
Benjamin Admin 25d5da78ef
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 34s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
feat: Alle 5 verbleibenden SDK-Module auf 100% — RAG, Security-Backlog, Quality, Notfallplan, Loeschfristen
Paket A — RAG Proxy:
- NEU: admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts
  → Proxy zu ai-compliance-sdk:8090, GET+POST, UUID-Validierung
- UPDATE: rag/page.tsx — setTimeout Mock → echte API-Calls
  GET /regulations → dynamische suggestedQuestions
  POST /search → Qdrant-Ergebnisse mit score, title, reference

Paket B — Security-Backlog + Quality:
- NEU: migrations/014_security_backlog.sql + 015_quality.sql
- NEU: compliance/api/security_backlog_routes.py — CRUD + Stats
- NEU: compliance/api/quality_routes.py — Metrics + Tests CRUD + Stats
- UPDATE: security-backlog/page.tsx — mockItems → API
- UPDATE: quality/page.tsx — mockMetrics/mockTests → API
- UPDATE: compliance/api/__init__.py — Router-Registrierung
- NEU: tests/test_security_backlog_routes.py (48 Tests — 48/48 bestanden)
- NEU: tests/test_quality_routes.py (67 Tests — 67/67 bestanden)

Paket C — Notfallplan Incidents + Templates:
- NEU: migrations/016_notfallplan_incidents.sql
  compliance_notfallplan_incidents + compliance_notfallplan_templates
- UPDATE: notfallplan_routes.py — GET/POST/PUT/DELETE für /incidents + /templates
- UPDATE: notfallplan/page.tsx — Incidents-Tab + Templates-Tab → API
- UPDATE: tests/test_notfallplan_routes.py (+76 neue Tests — alle bestanden)

Paket D — Loeschfristen localStorage → API:
- NEU: migrations/017_loeschfristen.sql (JSONB: legal_holds, storage_locations, ...)
- NEU: compliance/api/loeschfristen_routes.py — CRUD + Stats + Status-Update
- UPDATE: loeschfristen/page.tsx — vollständige localStorage → API Migration
  createNewPolicy → POST (API-UUID als id), deletePolicy → DELETE,
  handleSaveAndClose → PUT, adoptGeneratedPolicies → POST je Policy
  apiToPolicy() + policyToPayload() Mapper, saving-State für Buttons
- NEU: tests/test_loeschfristen_routes.py (58 Tests — alle bestanden)

Gesamt: 253 neue Tests, alle bestanden (48 + 67 + 76 + 58 + bestehende)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 18:04:53 +01:00

2323 lines
88 KiB
TypeScript

'use client'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
LoeschfristPolicy, LegalHold, StorageLocation,
RETENTION_DRIVER_META, RetentionDriverType, DeletionMethodType,
DELETION_METHOD_LABELS, STATUS_LABELS, STATUS_COLORS,
TRIGGER_LABELS, TRIGGER_COLORS, REVIEW_INTERVAL_LABELS,
STORAGE_LOCATION_LABELS, StorageLocationType, PolicyStatus,
ReviewInterval, DeletionTriggerLevel, RetentionUnit, LegalHoldStatus,
createEmptyPolicy, createEmptyLegalHold, createEmptyStorageLocation,
formatRetentionDuration, isPolicyOverdue, getActiveLegalHolds,
getEffectiveDeletionTrigger,
} from '@/lib/sdk/loeschfristen-types'
import { BASELINE_TEMPLATES, templateToPolicy, getTemplateById, getAllTemplateTags } from '@/lib/sdk/loeschfristen-baseline-catalog'
import {
PROFILING_STEPS, ProfilingAnswer, ProfilingStep,
isStepComplete, getProfilingProgress, generatePoliciesFromProfile,
} from '@/lib/sdk/loeschfristen-profiling'
import {
runComplianceCheck, ComplianceCheckResult, ComplianceIssue,
} from '@/lib/sdk/loeschfristen-compliance'
import {
exportPoliciesAsJSON, exportPoliciesAsCSV,
generateComplianceSummary, downloadFile,
} from '@/lib/sdk/loeschfristen-export'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export'
// ---------------------------------------------------------------------------
// Helper: TagInput
// ---------------------------------------------------------------------------
function TagInput({
value,
onChange,
placeholder,
}: {
value: string[]
onChange: (v: string[]) => void
placeholder?: string
}) {
const [input, setInput] = useState('')
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
const trimmed = input.trim().replace(/,+$/, '').trim()
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed])
}
setInput('')
}
}
const remove = (idx: number) => {
onChange(value.filter((_, i) => i !== idx))
}
return (
<div>
<div className="flex flex-wrap gap-1 mb-1">
{value.map((tag, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 bg-purple-100 text-purple-800 text-xs font-medium px-2 py-0.5 rounded-full"
>
{tag}
<button
type="button"
onClick={() => remove(idx)}
className="text-purple-600 hover:text-purple-900"
>
x
</button>
</span>
))}
</div>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? 'Eingabe + Enter'}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
export default function LoeschfristenPage() {
const router = useRouter()
const sdk = useSDK()
// ---- Core state ----
const [tab, setTab] = useState<Tab>('uebersicht')
const [policies, setPolicies] = useState<LoeschfristPolicy[]>([])
const [loaded, setLoaded] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [filter, setFilter] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
const [driverFilter, setDriverFilter] = useState<string>('all')
// ---- Generator state ----
const [profilingStep, setProfilingStep] = useState(0)
const [profilingAnswers, setProfilingAnswers] = useState<ProfilingAnswer[]>([])
const [generatedPolicies, setGeneratedPolicies] = useState<LoeschfristPolicy[]>([])
const [selectedGenerated, setSelectedGenerated] = useState<Set<string>>(new Set())
// ---- Compliance state ----
const [complianceResult, setComplianceResult] = useState<ComplianceCheckResult | null>(null)
// ---- Legal Hold management ----
const [managingLegalHolds, setManagingLegalHolds] = useState(false)
// ---- Saving state ----
const [saving, setSaving] = useState(false)
// ---- VVT data ----
const [vvtActivities, setVvtActivities] = useState<any[]>([])
// --------------------------------------------------------------------------
// Persistence (API-backed)
// --------------------------------------------------------------------------
const LOESCHFRISTEN_API = '/api/sdk/v1/compliance/loeschfristen'
useEffect(() => {
loadPolicies()
}, [])
async function loadPolicies() {
try {
const res = await fetch(`${LOESCHFRISTEN_API}?limit=500`)
if (res.ok) {
const data = await res.json()
const fetched = Array.isArray(data.policies)
? data.policies.map(apiToPolicy)
: []
setPolicies(fetched)
}
} catch (e) {
console.error('Failed to load Loeschfristen from API:', e)
}
setLoaded(true)
}
function apiToPolicy(raw: any): LoeschfristPolicy {
// Map snake_case API response to camelCase LoeschfristPolicy
const base = createEmptyPolicy()
return {
...base,
id: raw.id, // DB UUID — used for API calls
policyId: raw.policy_id || base.policyId, // Display ID like "LF-2026-001"
dataObjectName: raw.data_object_name || '',
description: raw.description || '',
affectedGroups: raw.affected_groups || [],
dataCategories: raw.data_categories || [],
primaryPurpose: raw.primary_purpose || '',
deletionTrigger: raw.deletion_trigger || 'PURPOSE_END',
retentionDriver: raw.retention_driver || null,
retentionDriverDetail: raw.retention_driver_detail || '',
retentionDuration: raw.retention_duration ?? null,
retentionUnit: raw.retention_unit || null,
retentionDescription: raw.retention_description || '',
startEvent: raw.start_event || '',
hasActiveLegalHold: raw.has_active_legal_hold || false,
legalHolds: raw.legal_holds || [],
storageLocations: raw.storage_locations || [],
deletionMethod: raw.deletion_method || 'MANUAL_REVIEW_DELETE',
deletionMethodDetail: raw.deletion_method_detail || '',
responsibleRole: raw.responsible_role || '',
responsiblePerson: raw.responsible_person || '',
releaseProcess: raw.release_process || '',
linkedVVTActivityIds: raw.linked_vvt_activity_ids || [],
status: raw.status || 'DRAFT',
lastReviewDate: raw.last_review_date || base.lastReviewDate,
nextReviewDate: raw.next_review_date || base.nextReviewDate,
reviewInterval: raw.review_interval || 'ANNUAL',
tags: raw.tags || [],
createdAt: raw.created_at || base.createdAt,
updatedAt: raw.updated_at || base.updatedAt,
}
}
function policyToPayload(p: LoeschfristPolicy): any {
return {
policy_id: p.policyId,
data_object_name: p.dataObjectName,
description: p.description,
affected_groups: p.affectedGroups,
data_categories: p.dataCategories,
primary_purpose: p.primaryPurpose,
deletion_trigger: p.deletionTrigger,
retention_driver: p.retentionDriver || null,
retention_driver_detail: p.retentionDriverDetail,
retention_duration: p.retentionDuration || null,
retention_unit: p.retentionUnit || null,
retention_description: p.retentionDescription,
start_event: p.startEvent,
has_active_legal_hold: p.hasActiveLegalHold,
legal_holds: p.legalHolds,
storage_locations: p.storageLocations,
deletion_method: p.deletionMethod,
deletion_method_detail: p.deletionMethodDetail,
responsible_role: p.responsibleRole,
responsible_person: p.responsiblePerson,
release_process: p.releaseProcess,
linked_vvt_activity_ids: p.linkedVVTActivityIds,
status: p.status,
last_review_date: p.lastReviewDate || null,
next_review_date: p.nextReviewDate || null,
review_interval: p.reviewInterval,
tags: p.tags,
}
}
// Load VVT activities from API
useEffect(() => {
fetch('/api/sdk/v1/compliance/vvt?limit=200')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data && Array.isArray(data.activities)) {
setVvtActivities(data.activities)
}
})
.catch(() => {
// fallback: try localStorage
try {
const raw = localStorage.getItem('bp_vvt')
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) setVvtActivities(parsed)
}
} catch { /* ignore */ }
})
}, [tab, editingId])
// --------------------------------------------------------------------------
// Derived
// --------------------------------------------------------------------------
const editingPolicy = useMemo(
() => policies.find((p) => p.policyId === editingId) ?? null,
[policies, editingId],
)
const filteredPolicies = useMemo(() => {
let result = [...policies]
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
result = result.filter(
(p) =>
p.dataObjectName.toLowerCase().includes(q) ||
p.policyId.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q),
)
}
if (filter === 'active') result = result.filter((p) => p.status === 'ACTIVE')
else if (filter === 'draft') result = result.filter((p) => p.status === 'DRAFT')
else if (filter === 'review')
result = result.filter((p) => isPolicyOverdue(p))
if (driverFilter !== 'all')
result = result.filter((p) => p.retentionDriver === driverFilter)
return result
}, [policies, searchQuery, filter, driverFilter])
const stats = useMemo(() => {
const total = policies.length
const active = policies.filter((p) => p.status === 'ACTIVE').length
const draft = policies.filter((p) => p.status === 'DRAFT').length
const overdue = policies.filter((p) => isPolicyOverdue(p)).length
const legalHolds = policies.reduce(
(acc, p) => acc + getActiveLegalHolds(p).length,
0,
)
return { total, active, draft, overdue, legalHolds }
}, [policies])
// --------------------------------------------------------------------------
// Handlers
// --------------------------------------------------------------------------
const updatePolicy = useCallback(
(id: string, updater: (p: LoeschfristPolicy) => LoeschfristPolicy) => {
setPolicies((prev) =>
prev.map((p) => (p.policyId === id ? updater(p) : p)),
)
},
[],
)
const createNewPolicy = useCallback(async () => {
setSaving(true)
try {
const empty = createEmptyPolicy()
const res = await fetch(LOESCHFRISTEN_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policyToPayload(empty)),
})
if (res.ok) {
const raw = await res.json()
const newP = apiToPolicy(raw)
setPolicies((prev) => [...prev, newP])
setEditingId(newP.policyId)
setTab('editor')
}
} catch (e) {
console.error('Failed to create policy:', e)
} finally {
setSaving(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const deletePolicy = useCallback(
async (policyId: string) => {
const policy = policies.find((p) => p.policyId === policyId)
if (!policy) return
try {
const res = await fetch(`${LOESCHFRISTEN_API}/${policy.id}`, { method: 'DELETE' })
if (res.ok || res.status === 204 || res.status === 404) {
setPolicies((prev) => prev.filter((p) => p.policyId !== policyId))
if (editingId === policyId) setEditingId(null)
}
} catch (e) {
console.error('Failed to delete policy:', e)
}
},
[editingId, policies],
)
const addLegalHold = useCallback(
(policyId: string) => {
updatePolicy(policyId, (p) => ({
...p,
legalHolds: [...p.legalHolds, createEmptyLegalHold()],
}))
},
[updatePolicy],
)
const removeLegalHold = useCallback(
(policyId: string, idx: number) => {
updatePolicy(policyId, (p) => ({
...p,
legalHolds: p.legalHolds.filter((_, i) => i !== idx),
}))
},
[updatePolicy],
)
const addStorageLocation = useCallback(
(policyId: string) => {
updatePolicy(policyId, (p) => ({
...p,
storageLocations: [...p.storageLocations, createEmptyStorageLocation()],
}))
},
[updatePolicy],
)
const removeStorageLocation = useCallback(
(policyId: string, idx: number) => {
updatePolicy(policyId, (p) => ({
...p,
storageLocations: p.storageLocations.filter((_, i) => i !== idx),
}))
},
[updatePolicy],
)
const handleProfilingAnswer = useCallback(
(stepIndex: number, questionId: string, value: any) => {
setProfilingAnswers((prev) => {
const existing = prev.findIndex(
(a) => a.stepIndex === stepIndex && a.questionId === questionId,
)
const answer: ProfilingAnswer = { stepIndex, questionId, value }
if (existing >= 0) {
const copy = [...prev]
copy[existing] = answer
return copy
}
return [...prev, answer]
})
},
[],
)
const handleGenerate = useCallback(() => {
const generated = generatePoliciesFromProfile(profilingAnswers)
setGeneratedPolicies(generated)
setSelectedGenerated(new Set(generated.map((p) => p.policyId)))
}, [profilingAnswers])
const adoptGeneratedPolicies = useCallback(
async (onlySelected: boolean) => {
const toAdopt = onlySelected
? generatedPolicies.filter((p) => selectedGenerated.has(p.policyId))
: generatedPolicies
setSaving(true)
try {
const created: LoeschfristPolicy[] = []
for (const p of toAdopt) {
try {
const res = await fetch(LOESCHFRISTEN_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policyToPayload(p)),
})
if (res.ok) {
const raw = await res.json()
created.push(apiToPolicy(raw))
} else {
created.push(p) // fallback: keep generated policy in state
}
} catch {
created.push(p)
}
}
setPolicies((prev) => [...prev, ...created])
} finally {
setSaving(false)
}
setGeneratedPolicies([])
setSelectedGenerated(new Set())
setProfilingStep(0)
setProfilingAnswers([])
setTab('uebersicht')
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[generatedPolicies, selectedGenerated],
)
const runCompliance = useCallback(() => {
const result = runComplianceCheck(policies)
setComplianceResult(result)
}, [policies])
const handleSaveAndClose = useCallback(async () => {
if (!editingPolicy) {
setEditingId(null)
setTab('uebersicht')
return
}
setSaving(true)
try {
const res = await fetch(`${LOESCHFRISTEN_API}/${editingPolicy.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(policyToPayload(editingPolicy)),
})
if (res.ok) {
const raw = await res.json()
const updated = apiToPolicy(raw)
setPolicies((prev) =>
prev.map((p) => (p.policyId === editingPolicy.policyId ? updated : p)),
)
}
} catch (e) {
console.error('Failed to save policy:', e)
} finally {
setSaving(false)
}
setEditingId(null)
setTab('uebersicht')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editingPolicy])
// --------------------------------------------------------------------------
// Tab definitions
// --------------------------------------------------------------------------
const TAB_CONFIG: { key: Tab; label: string }[] = [
{ key: 'uebersicht', label: 'Uebersicht' },
{ key: 'editor', label: 'Editor' },
{ key: 'generator', label: 'Generator' },
{ key: 'export', label: 'Export & Compliance' },
]
// --------------------------------------------------------------------------
// Render helpers
// --------------------------------------------------------------------------
const renderStatusBadge = (status: PolicyStatus) => {
const colors = STATUS_COLORS[status] ?? 'bg-gray-100 text-gray-800'
const label = STATUS_LABELS[status] ?? status
return (
<span
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${colors}`}
>
{label}
</span>
)
}
const renderTriggerBadge = (trigger: DeletionTriggerLevel) => {
const colors = TRIGGER_COLORS[trigger] ?? 'bg-gray-100 text-gray-800'
const label = TRIGGER_LABELS[trigger] ?? trigger
return (
<span
className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full ${colors}`}
>
{label}
</span>
)
}
// ==========================================================================
// TAB 1: Uebersicht
// ==========================================================================
const renderUebersicht = () => (
<div className="space-y-6">
{/* Stats bar */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ label: 'Gesamt', value: stats.total, color: 'text-gray-900' },
{ label: 'Aktiv', value: stats.active, color: 'text-green-600' },
{ label: 'Entwurf', value: stats.draft, color: 'text-yellow-600' },
{
label: 'Pruefung faellig',
value: stats.overdue,
color: 'text-red-600',
},
{
label: 'Legal Holds aktiv',
value: stats.legalHolds,
color: 'text-orange-600',
},
].map((s) => (
<div
key={s.label}
className="bg-white rounded-xl border border-gray-200 p-6 text-center"
>
<div className={`text-3xl font-bold ${s.color}`}>{s.value}</div>
<div className="text-sm text-gray-500 mt-1">{s.label}</div>
</div>
))}
</div>
{/* Search & filters */}
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-3">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suche nach Name, ID oder Beschreibung..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-gray-500 font-medium">Status:</span>
{[
{ key: 'all', label: 'Alle' },
{ key: 'active', label: 'Aktiv' },
{ key: 'draft', label: 'Entwurf' },
{ key: 'review', label: 'Pruefung noetig' },
].map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-3 py-1 rounded-lg text-sm font-medium transition ${
filter === f.key
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f.label}
</button>
))}
<span className="text-sm text-gray-500 font-medium ml-4">
Aufbewahrungstreiber:
</span>
<select
value={driverFilter}
onChange={(e) => setDriverFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle</option>
{Object.entries(RETENTION_DRIVER_META).map(([key, meta]) => (
<option key={key} value={key}>
{meta.label}
</option>
))}
</select>
</div>
</div>
{/* Policy cards or empty state */}
{filteredPolicies.length === 0 && policies.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="text-gray-400 text-5xl mb-4">&#128203;</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Noch keine Loeschfristen angelegt
</h3>
<p className="text-gray-500 mb-6">
Starten Sie den Generator, um auf Basis Ihres Unternehmensprofils
automatisch passende Loeschfristen zu erstellen, oder legen Sie
manuell eine neue Loeschfrist an.
</p>
<div className="flex justify-center gap-3">
<button
onClick={() => setTab('generator')}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition"
>
Generator starten
</button>
<button
onClick={createNewPolicy}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
>
Neue Loeschfrist
</button>
</div>
</div>
) : filteredPolicies.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<p className="text-gray-500">
Keine Loeschfristen entsprechen den aktuellen Filtern.
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPolicies.map((p) => {
const trigger = getEffectiveDeletionTrigger(p)
const activeHolds = getActiveLegalHolds(p)
const overdue = isPolicyOverdue(p)
return (
<div
key={p.policyId}
className="bg-white rounded-xl border border-gray-200 p-6 hover:shadow-md transition relative"
>
{activeHolds.length > 0 && (
<span
className="absolute top-3 right-3 text-orange-500"
title={`${activeHolds.length} aktive Legal Hold(s)`}
>
&#9888;
</span>
)}
<div className="text-xs text-gray-400 font-mono mb-1">
{p.policyId}
</div>
<h3 className="text-base font-semibold text-gray-900 mb-2 truncate">
{p.dataObjectName || 'Ohne Bezeichnung'}
</h3>
<div className="flex flex-wrap gap-1.5 mb-3">
{renderTriggerBadge(trigger)}
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">
{formatRetentionDuration(p)}
</span>
{renderStatusBadge(p.status)}
{overdue && (
<span className="inline-block text-xs font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-700">
Pruefung faellig
</span>
)}
</div>
{p.description && (
<p className="text-sm text-gray-500 mb-3 line-clamp-2">
{p.description}
</p>
)}
<button
onClick={() => {
setEditingId(p.policyId)
setTab('editor')
}}
className="text-sm text-purple-600 hover:text-purple-800 font-medium"
>
Bearbeiten &rarr;
</button>
</div>
)
})}
</div>
)}
{/* Floating action button */}
{policies.length > 0 && (
<div className="flex justify-end">
<button
onClick={createNewPolicy}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-5 py-2.5 font-medium transition shadow-sm"
>
+ Neue Loeschfrist
</button>
</div>
)}
</div>
)
// ==========================================================================
// TAB 2: Editor
// ==========================================================================
const renderEditorNoSelection = () => (
<div className="bg-white rounded-xl border border-gray-200 p-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Loeschfrist zum Bearbeiten waehlen
</h3>
{policies.length === 0 ? (
<p className="text-gray-500">
Noch keine Loeschfristen vorhanden.{' '}
<button
onClick={createNewPolicy}
className="text-purple-600 hover:text-purple-800 font-medium underline"
>
Neue Loeschfrist anlegen
</button>
</p>
) : (
<div className="space-y-2">
{policies.map((p) => (
<button
key={p.policyId}
onClick={() => setEditingId(p.policyId)}
className="w-full text-left px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition flex items-center justify-between"
>
<div>
<span className="text-xs text-gray-400 font-mono mr-2">
{p.policyId}
</span>
<span className="font-medium text-gray-900">
{p.dataObjectName || 'Ohne Bezeichnung'}
</span>
</div>
{renderStatusBadge(p.status)}
</button>
))}
<button
onClick={createNewPolicy}
className="w-full text-left px-4 py-3 border border-dashed border-gray-300 rounded-lg hover:bg-gray-50 transition text-purple-600 font-medium"
>
+ Neue Loeschfrist anlegen
</button>
</div>
)}
</div>
)
const renderEditorForm = (policy: LoeschfristPolicy) => {
const pid = policy.policyId
const set = <K extends keyof LoeschfristPolicy>(
key: K,
val: LoeschfristPolicy[K],
) => {
updatePolicy(pid, (p) => ({ ...p, [key]: val }))
}
const updateLegalHold = (
idx: number,
updater: (h: LegalHold) => LegalHold,
) => {
updatePolicy(pid, (p) => ({
...p,
legalHolds: p.legalHolds.map((h, i) => (i === idx ? updater(h) : h)),
}))
}
const updateStorageLocation = (
idx: number,
updater: (s: StorageLocation) => StorageLocation,
) => {
updatePolicy(pid, (p) => ({
...p,
storageLocations: p.storageLocations.map((s, i) =>
i === idx ? updater(s) : s,
),
}))
}
return (
<div className="space-y-6">
{/* Header with back button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => setEditingId(null)}
className="text-gray-400 hover:text-gray-600 transition"
>
&larr; Zurueck
</button>
<h2 className="text-lg font-semibold text-gray-900">
{policy.dataObjectName || 'Neue Loeschfrist'}
</h2>
<span className="text-xs text-gray-400 font-mono">
{policy.policyId}
</span>
</div>
{renderStatusBadge(policy.status)}
</div>
{/* Sektion 1: Datenobjekt */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
1. Datenobjekt
</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name des Datenobjekts *
</label>
<input
type="text"
value={policy.dataObjectName}
onChange={(e) => set('dataObjectName', e.target.value)}
placeholder="z.B. Bewerbungsunterlagen"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung
</label>
<textarea
value={policy.description}
onChange={(e) => set('description', e.target.value)}
rows={3}
placeholder="Beschreibung des Datenobjekts und seiner Verarbeitung..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Betroffene Personengruppen
</label>
<TagInput
value={policy.affectedGroups}
onChange={(v) => set('affectedGroups', v)}
placeholder="z.B. Bewerber, Mitarbeiter... (Enter zum Hinzufuegen)"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Datenkategorien
</label>
<TagInput
value={policy.dataCategories}
onChange={(v) => set('dataCategories', v)}
placeholder="z.B. Stammdaten, Kontaktdaten... (Enter zum Hinzufuegen)"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Primaerer Verarbeitungszweck
</label>
<textarea
value={policy.primaryPurpose}
onChange={(e) => set('primaryPurpose', e.target.value)}
rows={2}
placeholder="Welchem Zweck dient die Verarbeitung dieser Daten?"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
{/* Sektion 2: 3-stufige Loeschlogik */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
2. 3-stufige Loeschlogik
</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Loeschausloeser (Trigger-Stufe)
</label>
<div className="space-y-2">
{(
['PURPOSE_END', 'RETENTION_DRIVER', 'LEGAL_HOLD'] as DeletionTriggerLevel[]
).map((trigger) => (
<label
key={trigger}
className="flex items-start gap-3 p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer"
>
<input
type="radio"
name={`trigger-${pid}`}
checked={policy.deletionTrigger === trigger}
onChange={() => set('deletionTrigger', trigger)}
className="mt-0.5 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="flex items-center gap-2">
{renderTriggerBadge(trigger)}
</div>
<p className="text-xs text-gray-500 mt-1">
{trigger === 'PURPOSE_END' &&
'Loeschung nach Wegfall des Verarbeitungszwecks'}
{trigger === 'RETENTION_DRIVER' &&
'Loeschung nach Ablauf gesetzlicher oder vertraglicher Aufbewahrungsfrist'}
{trigger === 'LEGAL_HOLD' &&
'Loeschung durch aktiven Legal Hold blockiert'}
</p>
</div>
</label>
))}
</div>
</div>
{/* Retention driver selection */}
{policy.deletionTrigger === 'RETENTION_DRIVER' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Aufbewahrungstreiber
</label>
<select
value={policy.retentionDriver}
onChange={(e) => {
const driver = e.target.value as RetentionDriverType
const meta =
RETENTION_DRIVER_META[driver]
set('retentionDriver', driver)
if (meta) {
set('retentionDuration', meta.defaultDuration)
set('retentionUnit', meta.defaultUnit as RetentionUnit)
set('retentionDescription', meta.description)
}
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="">Bitte waehlen...</option>
{Object.entries(RETENTION_DRIVER_META).map(([key, meta]) => (
<option key={key} value={key}>
{meta.label}
</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Aufbewahrungsdauer
</label>
<input
type="number"
min={0}
value={policy.retentionDuration}
onChange={(e) =>
set('retentionDuration', parseInt(e.target.value) || 0)
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Einheit
</label>
<select
value={policy.retentionUnit}
onChange={(e) =>
set('retentionUnit', e.target.value as RetentionUnit)
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="DAYS">Tage</option>
<option value="MONTHS">Monate</option>
<option value="YEARS">Jahre</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Beschreibung der Aufbewahrungspflicht
</label>
<input
type="text"
value={policy.retentionDescription}
onChange={(e) => set('retentionDescription', e.target.value)}
placeholder="z.B. Handelsrechtliche Aufbewahrungspflicht gem. HGB"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Startereignis (Fristbeginn)
</label>
<input
type="text"
value={policy.startEvent}
onChange={(e) => set('startEvent', e.target.value)}
placeholder="z.B. Ende des Geschaeftsjahres, Vertragsende..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{/* Legal Holds */}
<div className="border-t border-gray-200 pt-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-800">
Legal Holds
</h4>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={policy.hasActiveLegalHold}
onChange={(e) =>
set('hasActiveLegalHold', e.target.checked)
}
className="text-purple-600 focus:ring-purple-500 rounded"
/>
Aktiver Legal Hold
</label>
</div>
{policy.legalHolds.length > 0 && (
<div className="overflow-x-auto mb-3">
<table className="w-full text-sm border border-gray-200 rounded-lg">
<thead>
<tr className="bg-gray-50">
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Bezeichnung
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Grund
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Status
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Erstellt am
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Aktion
</th>
</tr>
</thead>
<tbody>
{policy.legalHolds.map((hold, idx) => (
<tr key={idx} className="border-t border-gray-100">
<td className="px-3 py-2">
<input
type="text"
value={hold.name}
onChange={(e) =>
updateLegalHold(idx, (h) => ({
...h,
name: e.target.value,
}))
}
placeholder="Bezeichnung"
className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500"
/>
</td>
<td className="px-3 py-2">
<input
type="text"
value={hold.reason}
onChange={(e) =>
updateLegalHold(idx, (h) => ({
...h,
reason: e.target.value,
}))
}
placeholder="Grund"
className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500"
/>
</td>
<td className="px-3 py-2">
<select
value={hold.status}
onChange={(e) =>
updateLegalHold(idx, (h) => ({
...h,
status: e.target.value as LegalHoldStatus,
}))
}
className="px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500"
>
<option value="ACTIVE">Aktiv</option>
<option value="RELEASED">Aufgehoben</option>
<option value="EXPIRED">Abgelaufen</option>
</select>
</td>
<td className="px-3 py-2">
<input
type="date"
value={hold.createdAt}
onChange={(e) =>
updateLegalHold(idx, (h) => ({
...h,
createdAt: e.target.value,
}))
}
className="px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500"
/>
</td>
<td className="px-3 py-2">
<button
onClick={() => removeLegalHold(pid, idx)}
className="text-red-500 hover:text-red-700 text-sm font-medium"
>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<button
onClick={() => addLegalHold(pid)}
className="text-sm text-purple-600 hover:text-purple-800 font-medium"
>
+ Legal Hold hinzufuegen
</button>
</div>
</div>
{/* Sektion 3: Speicherorte & Loeschmethode */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
3. Speicherorte & Loeschmethode
</h3>
{policy.storageLocations.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm border border-gray-200 rounded-lg">
<thead>
<tr className="bg-gray-50">
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Name
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Typ
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Backup
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Anbieter
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Loeschfaehig
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Aktion
</th>
</tr>
</thead>
<tbody>
{policy.storageLocations.map((loc, idx) => (
<tr key={idx} className="border-t border-gray-100">
<td className="px-3 py-2">
<input
type="text"
value={loc.name}
onChange={(e) =>
updateStorageLocation(idx, (s) => ({
...s,
name: e.target.value,
}))
}
placeholder="Name"
className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500"
/>
</td>
<td className="px-3 py-2">
<select
value={loc.type}
onChange={(e) =>
updateStorageLocation(idx, (s) => ({
...s,
type: e.target.value as StorageLocationType,
}))
}
className="px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500"
>
{Object.entries(STORAGE_LOCATION_LABELS).map(
([key, label]) => (
<option key={key} value={key}>
{label}
</option>
),
)}
</select>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={loc.isBackup}
onChange={(e) =>
updateStorageLocation(idx, (s) => ({
...s,
isBackup: e.target.checked,
}))
}
className="text-purple-600 focus:ring-purple-500 rounded"
/>
</td>
<td className="px-3 py-2">
<input
type="text"
value={loc.provider}
onChange={(e) =>
updateStorageLocation(idx, (s) => ({
...s,
provider: e.target.value,
}))
}
placeholder="Anbieter"
className="w-full px-2 py-1 border border-gray-200 rounded text-sm focus:ring-1 focus:ring-purple-500"
/>
</td>
<td className="px-3 py-2 text-center">
<input
type="checkbox"
checked={loc.deletionCapable}
onChange={(e) =>
updateStorageLocation(idx, (s) => ({
...s,
deletionCapable: e.target.checked,
}))
}
className="text-purple-600 focus:ring-purple-500 rounded"
/>
</td>
<td className="px-3 py-2">
<button
onClick={() => removeStorageLocation(pid, idx)}
className="text-red-500 hover:text-red-700 text-sm font-medium"
>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<button
onClick={() => addStorageLocation(pid)}
className="text-sm text-purple-600 hover:text-purple-800 font-medium"
>
+ Speicherort hinzufuegen
</button>
<div className="border-t border-gray-200 pt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Loeschmethode
</label>
<select
value={policy.deletionMethod}
onChange={(e) =>
set('deletionMethod', e.target.value as DeletionMethodType)
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{Object.entries(DELETION_METHOD_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Details zur Loeschmethode
</label>
<textarea
value={policy.deletionMethodDetail}
onChange={(e) => set('deletionMethodDetail', e.target.value)}
rows={2}
placeholder="Weitere Details zum Loeschverfahren..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Sektion 4: Verantwortlichkeit */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
4. Verantwortlichkeit
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Verantwortliche Rolle
</label>
<input
type="text"
value={policy.responsibleRole}
onChange={(e) => set('responsibleRole', e.target.value)}
placeholder="z.B. Datenschutzbeauftragter"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Verantwortliche Person
</label>
<input
type="text"
value={policy.responsiblePerson}
onChange={(e) => set('responsiblePerson', e.target.value)}
placeholder="Name der verantwortlichen Person"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Freigabeprozess
</label>
<textarea
value={policy.releaseProcess}
onChange={(e) => set('releaseProcess', e.target.value)}
rows={3}
placeholder="Beschreibung des Freigabeprozesses fuer Loeschungen..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
{/* Sektion 5: VVT-Verknuepfung */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
5. VVT-Verknuepfung
</h3>
{vvtActivities.length > 0 ? (
<div>
<p className="text-sm text-gray-500 mb-3">
Verknuepfen Sie diese Loeschfrist mit einer
Verarbeitungstaetigkeit aus Ihrem VVT.
</p>
<div className="space-y-2">
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && (
<div className="mb-3">
<label className="block text-xs font-medium text-gray-500 mb-1">
Verknuepfte Taetigkeiten:
</label>
<div className="flex flex-wrap gap-1">
{policy.linkedVvtIds.map((vvtId: string) => {
const activity = vvtActivities.find(
(a: any) => a.id === vvtId,
)
return (
<span
key={vvtId}
className="inline-flex items-center gap-1 bg-blue-100 text-blue-800 text-xs font-medium px-2 py-0.5 rounded-full"
>
{activity?.name || vvtId}
<button
type="button"
onClick={() =>
updatePolicy(pid, (p) => ({
...p,
linkedVvtIds: (
p.linkedVvtIds || []
).filter((id: string) => id !== vvtId),
}))
}
className="text-blue-600 hover:text-blue-900"
>
x
</button>
</span>
)
})}
</div>
</div>
)}
<select
onChange={(e) => {
const val = e.target.value
if (
val &&
!(policy.linkedVvtIds || []).includes(val)
) {
updatePolicy(pid, (p) => ({
...p,
linkedVvtIds: [...(p.linkedVvtIds || []), val],
}))
}
e.target.value = ''
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="">
Verarbeitungstaetigkeit verknuepfen...
</option>
{vvtActivities
.filter(
(a: any) =>
!(policy.linkedVvtIds || []).includes(a.id),
)
.map((a: any) => (
<option key={a.id} value={a.id}>
{a.name || a.id}
</option>
))}
</select>
</div>
</div>
) : (
<p className="text-sm text-gray-400">
Kein VVT gefunden. Erstellen Sie zuerst ein
Verarbeitungsverzeichnis, um hier Verknuepfungen herstellen zu
koennen.
</p>
)}
</div>
{/* Sektion 6: Review-Einstellungen */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
6. Review-Einstellungen
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={policy.status}
onChange={(e) =>
set('status', e.target.value as PolicyStatus)
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{Object.entries(STATUS_LABELS).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Pruefintervall
</label>
<select
value={policy.reviewInterval}
onChange={(e) =>
set('reviewInterval', e.target.value as ReviewInterval)
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{Object.entries(REVIEW_INTERVAL_LABELS).map(
([key, label]) => (
<option key={key} value={key}>
{label}
</option>
),
)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Letzte Pruefung
</label>
<input
type="date"
value={policy.lastReviewDate}
onChange={(e) => set('lastReviewDate', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Naechste Pruefung
</label>
<input
type="date"
value={policy.nextReviewDate}
onChange={(e) => set('nextReviewDate', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tags
</label>
<TagInput
value={policy.tags}
onChange={(v) => set('tags', v)}
placeholder="Tags hinzufuegen (Enter zum Bestaetigen)"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
<button
onClick={() => {
if (
confirm(
'Moechten Sie diese Loeschfrist wirklich loeschen?',
)
) {
deletePolicy(pid)
setTab('uebersicht')
}
}}
className="text-red-600 hover:text-red-800 font-medium text-sm"
>
Loeschfrist loeschen
</button>
<div className="flex gap-3">
<button
onClick={() => {
setEditingId(null)
setTab('uebersicht')
}}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
>
Zurueck zur Uebersicht
</button>
<button
onClick={handleSaveAndClose}
disabled={saving}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 rounded-lg px-4 py-2 font-medium transition"
>
{saving ? 'Speichern...' : 'Speichern & Schliessen'}
</button>
</div>
</div>
</div>
)
}
const renderEditor = () => {
if (!editingId || !editingPolicy) {
return renderEditorNoSelection()
}
return renderEditorForm(editingPolicy)
}
// ==========================================================================
// TAB 3: Generator
// ==========================================================================
const renderGenerator = () => {
const totalSteps = PROFILING_STEPS.length
const progress = getProfilingProgress(profilingAnswers)
const allComplete = PROFILING_STEPS.every((step, idx) =>
isStepComplete(step, profilingAnswers.filter((a) => a.stepIndex === idx)),
)
// If we have generated policies, show the preview
if (generatedPolicies.length > 0) {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Generierte Loeschfristen
</h3>
<p className="text-sm text-gray-500 mb-4">
Auf Basis Ihres Profils wurden {generatedPolicies.length}{' '}
Loeschfristen generiert. Waehlen Sie die relevanten aus und
uebernehmen Sie sie.
</p>
<div className="flex gap-3 mb-4">
<button
onClick={() =>
setSelectedGenerated(
new Set(generatedPolicies.map((p) => p.policyId)),
)
}
className="text-sm text-purple-600 hover:text-purple-800 font-medium"
>
Alle auswaehlen
</button>
<button
onClick={() => setSelectedGenerated(new Set())}
className="text-sm text-gray-500 hover:text-gray-700 font-medium"
>
Alle abwaehlen
</button>
</div>
<div className="space-y-2 max-h-[500px] overflow-y-auto">
{generatedPolicies.map((gp) => {
const selected = selectedGenerated.has(gp.policyId)
return (
<label
key={gp.policyId}
className={`flex items-start gap-3 p-4 border rounded-lg cursor-pointer transition ${
selected
? 'border-purple-300 bg-purple-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={selected}
onChange={(e) => {
const next = new Set(selectedGenerated)
if (e.target.checked) next.add(gp.policyId)
else next.delete(gp.policyId)
setSelectedGenerated(next)
}}
className="mt-1 text-purple-600 focus:ring-purple-500 rounded"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">
{gp.dataObjectName}
</span>
<span className="text-xs font-mono text-gray-400">
{gp.policyId}
</span>
</div>
<p className="text-sm text-gray-500 mb-1">
{gp.description}
</p>
<div className="flex flex-wrap gap-1">
{renderTriggerBadge(
getEffectiveDeletionTrigger(gp),
)}
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">
{formatRetentionDuration(gp)}
</span>
{gp.retentionDriver && (
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">
{RETENTION_DRIVER_META[gp.retentionDriver]
?.label || gp.retentionDriver}
</span>
)}
</div>
</div>
</label>
)
})}
</div>
</div>
<div className="flex justify-between">
<button
onClick={() => {
setGeneratedPolicies([])
setSelectedGenerated(new Set())
}}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
>
Zurueck zum Profiling
</button>
<div className="flex gap-3">
<button
onClick={() => adoptGeneratedPolicies(false)}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
>
Alle uebernehmen ({generatedPolicies.length})
</button>
<button
onClick={() => adoptGeneratedPolicies(true)}
disabled={selectedGenerated.size === 0}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Ausgewaehlte uebernehmen ({selectedGenerated.size})
</button>
</div>
</div>
</div>
)
}
// Profiling wizard
const currentStep: ProfilingStep | undefined = PROFILING_STEPS[profilingStep]
return (
<div className="space-y-6">
{/* Progress bar */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900">
Profiling-Assistent
</h3>
<span className="text-sm text-gray-500">
Schritt {profilingStep + 1} von {totalSteps}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all duration-300"
style={{
width: `${Math.round(progress * 100)}%`,
}}
/>
</div>
<div className="flex justify-between mt-2">
{PROFILING_STEPS.map((step, idx) => (
<button
key={idx}
onClick={() => setProfilingStep(idx)}
className={`text-xs font-medium transition ${
idx === profilingStep
? 'text-purple-600'
: idx < profilingStep
? 'text-green-600'
: 'text-gray-400'
}`}
>
{step.title}
</button>
))}
</div>
</div>
{/* Current step questions */}
{currentStep && (
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-1">
{currentStep.title}
</h3>
{currentStep.description && (
<p className="text-sm text-gray-500">
{currentStep.description}
</p>
)}
</div>
{currentStep.questions.map((question) => {
const currentAnswer = profilingAnswers.find(
(a) =>
a.stepIndex === profilingStep &&
a.questionId === question.id,
)
return (
<div
key={question.id}
className="border-t border-gray-100 pt-4"
>
<label className="block text-sm font-medium text-gray-700 mb-2">
{question.label}
{question.helpText && (
<span className="block text-xs text-gray-400 font-normal mt-0.5">
{question.helpText}
</span>
)}
</label>
{/* Boolean */}
{question.type === 'boolean' && (
<div className="flex gap-3">
{[
{ val: true, label: 'Ja' },
{ val: false, label: 'Nein' },
].map((opt) => (
<button
key={String(opt.val)}
onClick={() =>
handleProfilingAnswer(
profilingStep,
question.id,
opt.val,
)
}
className={`px-4 py-2 rounded-lg text-sm font-medium transition ${
currentAnswer?.value === opt.val
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{opt.label}
</button>
))}
</div>
)}
{/* Single select */}
{question.type === 'single' && question.options && (
<div className="space-y-2">
{question.options.map((opt) => (
<label
key={opt.value}
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${
currentAnswer?.value === opt.value
? 'border-purple-300 bg-purple-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="radio"
name={`${question.id}-${profilingStep}`}
checked={currentAnswer?.value === opt.value}
onChange={() =>
handleProfilingAnswer(
profilingStep,
question.id,
opt.value,
)
}
className="text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="text-sm font-medium text-gray-900">
{opt.label}
</span>
{opt.description && (
<span className="block text-xs text-gray-500">
{opt.description}
</span>
)}
</div>
</label>
))}
</div>
)}
{/* Multi select */}
{question.type === 'multi' && question.options && (
<div className="space-y-2">
{question.options.map((opt) => {
const selectedValues: string[] =
currentAnswer?.value || []
const isSelected = selectedValues.includes(opt.value)
return (
<label
key={opt.value}
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${
isSelected
? 'border-purple-300 bg-purple-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
let next: string[]
if (e.target.checked) {
next = [...selectedValues, opt.value]
} else {
next = selectedValues.filter(
(v) => v !== opt.value,
)
}
handleProfilingAnswer(
profilingStep,
question.id,
next,
)
}}
className="text-purple-600 focus:ring-purple-500 rounded"
/>
<div>
<span className="text-sm font-medium text-gray-900">
{opt.label}
</span>
{opt.description && (
<span className="block text-xs text-gray-500">
{opt.description}
</span>
)}
</div>
</label>
)
})}
</div>
)}
{/* Number input */}
{question.type === 'number' && (
<input
type="number"
value={currentAnswer?.value ?? ''}
onChange={(e) =>
handleProfilingAnswer(
profilingStep,
question.id,
e.target.value ? parseInt(e.target.value) : '',
)
}
min={0}
placeholder="Bitte Zahl eingeben"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
)}
</div>
)
})}
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={() => setProfilingStep((s) => Math.max(0, s - 1))}
disabled={profilingStep === 0}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Zurueck
</button>
{profilingStep < totalSteps - 1 ? (
<button
onClick={() =>
setProfilingStep((s) => Math.min(totalSteps - 1, s + 1))
}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition"
>
Weiter
</button>
) : (
<button
onClick={handleGenerate}
disabled={!allComplete}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-5 py-2.5 font-semibold transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Loeschfristen generieren
</button>
)}
</div>
</div>
)
}
// ==========================================================================
// TAB 4: Export & Compliance
// ==========================================================================
const renderExport = () => {
const allLegalHolds = policies.flatMap((p) =>
p.legalHolds.map((h) => ({
...h,
policyId: p.policyId,
policyName: p.dataObjectName,
})),
)
const activeLegalHolds = allLegalHolds.filter(
(h) => h.status === 'ACTIVE',
)
return (
<div className="space-y-6">
{/* Compliance Check */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">
Compliance-Check
</h3>
<button
onClick={runCompliance}
disabled={policies.length === 0}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition disabled:opacity-50 disabled:cursor-not-allowed"
>
Analyse starten
</button>
</div>
{policies.length === 0 && (
<p className="text-sm text-gray-400">
Erstellen Sie zuerst Loeschfristen, um eine Compliance-Analyse
durchzufuehren.
</p>
)}
{complianceResult && (
<div className="space-y-4">
{/* Score */}
<div className="flex items-center gap-4 p-4 rounded-lg bg-gray-50">
<div
className={`text-4xl font-bold ${
complianceResult.score >= 75
? 'text-green-600'
: complianceResult.score >= 50
? 'text-yellow-600'
: 'text-red-600'
}`}
>
{complianceResult.score}
</div>
<div>
<div className="text-sm font-medium text-gray-900">
Compliance-Score
</div>
<div className="text-xs text-gray-500">
{complianceResult.score >= 75
? 'Guter Zustand - wenige Optimierungen noetig'
: complianceResult.score >= 50
? 'Verbesserungsbedarf - wichtige Punkte offen'
: 'Kritisch - dringender Handlungsbedarf'}
</div>
</div>
</div>
{/* Issues grouped by severity */}
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const).map(
(severity) => {
const issues = complianceResult.issues.filter(
(i) => i.severity === severity,
)
if (issues.length === 0) return null
const severityConfig = {
CRITICAL: {
label: 'Kritisch',
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-800',
badge: 'bg-red-100 text-red-800',
},
HIGH: {
label: 'Hoch',
bg: 'bg-orange-50',
border: 'border-orange-200',
text: 'text-orange-800',
badge: 'bg-orange-100 text-orange-800',
},
MEDIUM: {
label: 'Mittel',
bg: 'bg-yellow-50',
border: 'border-yellow-200',
text: 'text-yellow-800',
badge: 'bg-yellow-100 text-yellow-800',
},
LOW: {
label: 'Niedrig',
bg: 'bg-blue-50',
border: 'border-blue-200',
text: 'text-blue-800',
badge: 'bg-blue-100 text-blue-800',
},
}[severity]
return (
<div key={severity}>
<div className="flex items-center gap-2 mb-2">
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${severityConfig.badge}`}
>
{severityConfig.label}
</span>
<span className="text-xs text-gray-400">
{issues.length}{' '}
{issues.length === 1 ? 'Problem' : 'Probleme'}
</span>
</div>
<div className="space-y-2">
{issues.map((issue, idx) => (
<div
key={idx}
className={`p-3 rounded-lg border ${severityConfig.bg} ${severityConfig.border}`}
>
<div
className={`text-sm font-medium ${severityConfig.text}`}
>
{issue.title}
</div>
<p className="text-xs text-gray-600 mt-1">
{issue.description}
</p>
{issue.recommendation && (
<p className="text-xs text-gray-500 mt-1 italic">
Empfehlung: {issue.recommendation}
</p>
)}
{issue.affectedPolicyId && (
<button
onClick={() => {
setEditingId(issue.affectedPolicyId!)
setTab('editor')
}}
className="text-xs text-purple-600 hover:text-purple-800 font-medium mt-1"
>
Zur Loeschfrist: {issue.affectedPolicyId}
</button>
)}
</div>
))}
</div>
</div>
)
},
)}
{complianceResult.issues.length === 0 && (
<div className="p-4 rounded-lg bg-green-50 border border-green-200 text-center">
<div className="text-green-700 font-medium">
Keine Compliance-Probleme gefunden
</div>
<p className="text-xs text-green-600 mt-1">
Alle Loeschfristen entsprechen den Anforderungen.
</p>
</div>
)}
</div>
)}
</div>
{/* Legal Hold Management */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Legal Hold Verwaltung
</h3>
{allLegalHolds.length === 0 ? (
<p className="text-sm text-gray-400">
Keine Legal Holds vorhanden.
</p>
) : (
<div>
<div className="flex gap-4 mb-4">
<div className="text-sm">
<span className="font-medium text-gray-700">Gesamt:</span>{' '}
<span className="text-gray-900">
{allLegalHolds.length}
</span>
</div>
<div className="text-sm">
<span className="font-medium text-orange-600">Aktiv:</span>{' '}
<span className="text-gray-900">
{activeLegalHolds.length}
</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm border border-gray-200 rounded-lg">
<thead>
<tr className="bg-gray-50">
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Loeschfrist
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Bezeichnung
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Grund
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Status
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">
Erstellt
</th>
</tr>
</thead>
<tbody>
{allLegalHolds.map((hold, idx) => (
<tr
key={idx}
className="border-t border-gray-100"
>
<td className="px-3 py-2">
<button
onClick={() => {
setEditingId(hold.policyId)
setTab('editor')
}}
className="text-purple-600 hover:text-purple-800 font-medium text-xs"
>
{hold.policyName || hold.policyId}
</button>
</td>
<td className="px-3 py-2 text-gray-900">
{hold.name || '-'}
</td>
<td className="px-3 py-2 text-gray-500">
{hold.reason || '-'}
</td>
<td className="px-3 py-2">
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
hold.status === 'ACTIVE'
? 'bg-orange-100 text-orange-800'
: hold.status === 'RELEASED'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}
>
{hold.status === 'ACTIVE'
? 'Aktiv'
: hold.status === 'RELEASED'
? 'Aufgehoben'
: 'Abgelaufen'}
</span>
</td>
<td className="px-3 py-2 text-gray-500 text-xs">
{hold.createdAt || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
{/* Export */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
Datenexport
</h3>
<p className="text-sm text-gray-500">
Exportieren Sie Ihre Loeschfristen und den Compliance-Status in
verschiedenen Formaten.
</p>
{policies.length === 0 ? (
<p className="text-sm text-gray-400">
Erstellen Sie zuerst Loeschfristen, um Exporte zu generieren.
</p>
) : (
<div className="flex flex-wrap gap-3">
<button
onClick={() =>
downloadFile(
exportPoliciesAsJSON(policies),
'loeschfristen-export.json',
'application/json',
)
}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
>
JSON Export
</button>
<button
onClick={() =>
downloadFile(
exportPoliciesAsCSV(policies),
'loeschfristen-export.csv',
'text/csv;charset=utf-8',
)
}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
>
CSV Export
</button>
<button
onClick={() =>
downloadFile(
generateComplianceSummary(policies),
'compliance-bericht.md',
'text/markdown',
)
}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
>
Compliance-Bericht
</button>
</div>
)}
</div>
</div>
)
}
// ==========================================================================
// Main render
// ==========================================================================
if (!loaded) {
return (
<div className="p-8 text-center text-gray-500">
Lade Loeschfristen...
</div>
)
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader stepId="loeschfristen" {...STEP_EXPLANATIONS['loeschfristen']} />
{/* Tab bar */}
<div className="flex gap-1 bg-gray-100 p-1 rounded-xl">
{TAB_CONFIG.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition ${
tab === t.key
? 'bg-purple-600 text-white shadow-sm'
: 'bg-transparent text-gray-600 hover:bg-gray-200'
}`}
>
{t.label}
</button>
))}
</div>
{/* Tab content */}
{tab === 'uebersicht' && renderUebersicht()}
{tab === 'editor' && renderEditor()}
{tab === 'generator' && renderGenerator()}
{tab === 'export' && renderExport()}
</div>
)
}