Files
breakpilot-compliance/admin-compliance/app/sdk/loeschfristen/page.tsx
Sharang Parnerkar 6c883fb12e refactor(admin): split loeschfristen + dsb-portal page.tsx into colocated components
Split two oversized page files into _components/ directories following
Next.js 15 conventions and the 500-LOC hard cap:

- loeschfristen/page.tsx (2322 LOC -> 412 LOC orchestrator + 6 components)
- dsb-portal/page.tsx (2068 LOC -> 135 LOC orchestrator + 9 components)

All component files stay under 500 lines. Build verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:51:16 +02:00

413 lines
16 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,
createEmptyPolicy, createEmptyLegalHold, createEmptyStorageLocation,
isPolicyOverdue, getActiveLegalHolds,
} from '@/lib/sdk/loeschfristen-types'
import { ProfilingAnswer, generatePoliciesFromProfile } from '@/lib/sdk/loeschfristen-profiling'
import { runComplianceCheck, ComplianceCheckResult } from '@/lib/sdk/loeschfristen-compliance'
import { UebersichtTab } from './_components/UebersichtTab'
import { EditorTab } from './_components/EditorTab'
import { GeneratorTab } from './_components/GeneratorTab'
import { ExportTab } from './_components/ExportTab'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export'
const TAB_CONFIG: { key: Tab; label: string }[] = [
{ key: 'uebersicht', label: 'Uebersicht' },
{ key: 'editor', label: 'Editor' },
{ key: 'generator', label: 'Generator' },
{ key: 'export', label: 'Export & Compliance' },
]
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
const LOESCHFRISTEN_API = '/api/sdk/v1/compliance/loeschfristen'
function apiToPolicy(raw: any): LoeschfristPolicy {
const base = createEmptyPolicy()
return {
...base,
id: raw.id,
policyId: raw.policy_id || base.policyId,
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,
}
}
// ---------------------------------------------------------------------------
// 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)
// ---- Saving state ----
const [saving, setSaving] = useState(false)
// ---- VVT data ----
const [vvtActivities, setVvtActivities] = useState<any[]>([])
// --------------------------------------------------------------------------
// Persistence (API-backed)
// --------------------------------------------------------------------------
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)
}
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(() => {
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) { created.push(apiToPolicy(await res.json())) }
else { created.push(p) }
} 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(() => {
setComplianceResult(runComplianceCheck(policies))
}, [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 updated = apiToPolicy(await res.json())
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])
// --------------------------------------------------------------------------
// Render
// --------------------------------------------------------------------------
if (!loaded) {
return <div className="p-8 text-center text-gray-500">Lade Loeschfristen...</div>
}
return (
<div className="space-y-6">
<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' && (
<UebersichtTab
policies={policies} filteredPolicies={filteredPolicies} stats={stats}
searchQuery={searchQuery} setSearchQuery={setSearchQuery}
filter={filter} setFilter={setFilter}
driverFilter={driverFilter} setDriverFilter={setDriverFilter}
setTab={setTab} setEditingId={setEditingId} createNewPolicy={createNewPolicy}
/>
)}
{tab === 'editor' && (
<EditorTab
policies={policies} editingId={editingId} editingPolicy={editingPolicy}
vvtActivities={vvtActivities} saving={saving}
setEditingId={setEditingId} setTab={setTab}
updatePolicy={updatePolicy} createNewPolicy={createNewPolicy}
deletePolicy={deletePolicy}
addLegalHold={addLegalHold} removeLegalHold={removeLegalHold}
addStorageLocation={addStorageLocation} removeStorageLocation={removeStorageLocation}
handleSaveAndClose={handleSaveAndClose}
/>
)}
{tab === 'generator' && (
<GeneratorTab
profilingStep={profilingStep} setProfilingStep={setProfilingStep}
profilingAnswers={profilingAnswers} handleProfilingAnswer={handleProfilingAnswer}
generatedPolicies={generatedPolicies} setGeneratedPolicies={setGeneratedPolicies}
selectedGenerated={selectedGenerated} setSelectedGenerated={setSelectedGenerated}
handleGenerate={handleGenerate} adoptGeneratedPolicies={adoptGeneratedPolicies}
/>
)}
{tab === 'export' && (
<ExportTab
policies={policies} complianceResult={complianceResult}
runCompliance={runCompliance} setEditingId={setEditingId} setTab={setTab}
/>
)}
</div>
)
}