Reduce both page.tsx files below the 500-LOC hard cap by extracting all inline tab components and API helpers into colocated _components/. - loeschfristen/page.tsx: 2720 → 467 LOC - vvt/page.tsx: 2297 → 256 LOC New files: LoeschkonzeptTab, loeschfristen/api, TabDokument, TabProcessor Updated: TabVerzeichnis (template picker + badge), vvt/api (template helpers) Fixed: VVTLinkSection wrong field name (linkedVVTActivityIds), VendorLinkSection added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
468 lines
17 KiB
TypeScript
468 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
|
import {
|
|
LoeschfristPolicy,
|
|
createEmptyLegalHold, createEmptyStorageLocation,
|
|
isPolicyOverdue, getActiveLegalHolds,
|
|
} from '@/lib/sdk/loeschfristen-types'
|
|
import {
|
|
PROFILING_STEPS, ProfilingAnswer,
|
|
isStepComplete, getProfilingProgress, generatePoliciesFromProfile,
|
|
} from '@/lib/sdk/loeschfristen-profiling'
|
|
import { runComplianceCheck, ComplianceCheckResult } from '@/lib/sdk/loeschfristen-compliance'
|
|
import {
|
|
buildLoeschkonzeptHtml,
|
|
type LoeschkonzeptOrgHeader,
|
|
type LoeschkonzeptRevision,
|
|
createDefaultLoeschkonzeptOrgHeader,
|
|
} from '@/lib/sdk/loeschfristen-document'
|
|
import { LOESCHFRISTEN_API, apiToPolicy, policyToPayload } from './_components/api'
|
|
import { UebersichtTab } from './_components/UebersichtTab'
|
|
import { EditorTab } from './_components/EditorTab'
|
|
import { GeneratorTab } from './_components/GeneratorTab'
|
|
import { ExportTab } from './_components/ExportTab'
|
|
import { LoeschkonzeptTab } from './_components/LoeschkonzeptTab'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export' | 'loeschkonzept'
|
|
|
|
const TAB_CONFIG: { key: Tab; label: string }[] = [
|
|
{ key: 'uebersicht', label: 'Uebersicht' },
|
|
{ key: 'editor', label: 'Editor' },
|
|
{ key: 'generator', label: 'Generator' },
|
|
{ key: 'export', label: 'Export & Compliance' },
|
|
{ key: 'loeschkonzept', label: 'Loeschkonzept' },
|
|
]
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main Page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function LoeschfristenPage() {
|
|
// ---- 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[]>([])
|
|
|
|
// ---- Vendor data ----
|
|
const [vendorList, setVendorList] = useState<Array<{id: string, name: string}>>([])
|
|
|
|
// ---- Loeschkonzept document state ----
|
|
const [orgHeader, setOrgHeader] = useState<LoeschkonzeptOrgHeader>(createDefaultLoeschkonzeptOrgHeader())
|
|
const [revisions, setRevisions] = useState<LoeschkonzeptRevision[]>([])
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Data loading
|
|
// --------------------------------------------------------------------------
|
|
|
|
useEffect(() => {
|
|
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)
|
|
}
|
|
loadPolicies()
|
|
}, [])
|
|
|
|
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])
|
|
|
|
useEffect(() => {
|
|
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(data => {
|
|
const items = data?.data?.items || []
|
|
setVendorList(items.map((v: any) => ({ id: v.id, name: v.name })))
|
|
})
|
|
.catch(() => {})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const raw = localStorage.getItem('bp_loeschkonzept_revisions')
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw)
|
|
if (Array.isArray(parsed)) setRevisions(parsed)
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
try {
|
|
const raw = localStorage.getItem('bp_loeschkonzept_orgheader')
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw)
|
|
if (parsed && typeof parsed === 'object') {
|
|
setOrgHeader(prev => ({ ...prev, ...parsed }))
|
|
return
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
fetch('/api/sdk/v1/compliance/vvt/organization')
|
|
.then(res => res.ok ? res.json() : null)
|
|
.then(data => {
|
|
if (data) {
|
|
setOrgHeader(prev => ({
|
|
...prev,
|
|
organizationName: data.organization_name || data.organizationName || prev.organizationName,
|
|
industry: data.industry || prev.industry,
|
|
dpoName: data.dpo_name || data.dpoName || prev.dpoName,
|
|
dpoContact: data.dpo_contact || data.dpoContact || prev.dpoContact,
|
|
responsiblePerson: data.responsible_person || data.responsiblePerson || prev.responsiblePerson,
|
|
employeeCount: data.employee_count || data.employeeCount || prev.employeeCount,
|
|
}))
|
|
}
|
|
})
|
|
.catch(() => { /* ignore */ })
|
|
}, [])
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Derived state
|
|
// --------------------------------------------------------------------------
|
|
|
|
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])
|
|
|
|
const handleOrgHeaderChange = useCallback((field: keyof LoeschkonzeptOrgHeader, value: string | string[]) => {
|
|
const updated = { ...orgHeader, [field]: value }
|
|
setOrgHeader(updated)
|
|
localStorage.setItem('bp_loeschkonzept_orgheader', JSON.stringify(updated))
|
|
}, [orgHeader])
|
|
|
|
const handleAddRevision = useCallback(() => {
|
|
const newRev: LoeschkonzeptRevision = {
|
|
version: orgHeader.loeschkonzeptVersion,
|
|
date: new Date().toISOString().split('T')[0],
|
|
author: orgHeader.dpoName || orgHeader.responsiblePerson || '',
|
|
changes: '',
|
|
}
|
|
const updated = [...revisions, newRev]
|
|
setRevisions(updated)
|
|
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
|
|
}, [orgHeader, revisions])
|
|
|
|
const handleUpdateRevision = useCallback((index: number, field: keyof LoeschkonzeptRevision, value: string) => {
|
|
const updated = revisions.map((r, i) => i === index ? { ...r, [field]: value } : r)
|
|
setRevisions(updated)
|
|
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
|
|
}, [revisions])
|
|
|
|
const handleRemoveRevision = useCallback((index: number) => {
|
|
const updated = revisions.filter((_, i) => i !== index)
|
|
setRevisions(updated)
|
|
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
|
|
}, [revisions])
|
|
|
|
// --------------------------------------------------------------------------
|
|
// 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={(t) => setTab(t as Tab)}
|
|
setEditingId={setEditingId}
|
|
createNewPolicy={createNewPolicy}
|
|
/>
|
|
)}
|
|
|
|
{tab === 'editor' && (
|
|
<EditorTab
|
|
policies={policies}
|
|
editingId={editingId}
|
|
editingPolicy={editingPolicy}
|
|
vvtActivities={vvtActivities}
|
|
vendorList={vendorList}
|
|
saving={saving}
|
|
setEditingId={setEditingId}
|
|
setTab={(t) => setTab(t as Tab)}
|
|
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={(t) => setTab(t as Tab)}
|
|
/>
|
|
)}
|
|
|
|
{tab === 'loeschkonzept' && (
|
|
<LoeschkonzeptTab
|
|
policies={policies}
|
|
orgHeader={orgHeader}
|
|
revisions={revisions}
|
|
complianceResult={complianceResult}
|
|
vvtActivities={vvtActivities}
|
|
onOrgHeaderChange={handleOrgHeaderChange}
|
|
onAddRevision={handleAddRevision}
|
|
onUpdateRevision={handleUpdateRevision}
|
|
onRemoveRevision={handleRemoveRevision}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|