All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2323 lines
88 KiB
TypeScript
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">📋</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)`}
|
|
>
|
|
⚠
|
|
</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 →
|
|
</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"
|
|
>
|
|
← 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>
|
|
)
|
|
}
|