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>
This commit is contained in:
Sharang Parnerkar
2026-04-11 18:51:16 +02:00
parent f7b77fd504
commit 6c883fb12e
16 changed files with 3160 additions and 4057 deletions

View File

@@ -0,0 +1,460 @@
'use client'
import React from 'react'
import {
LoeschfristPolicy, LegalHold, StorageLocation,
RETENTION_DRIVER_META, RetentionDriverType, DeletionMethodType,
DELETION_METHOD_LABELS, STATUS_LABELS,
STORAGE_LOCATION_LABELS, StorageLocationType, PolicyStatus,
ReviewInterval, DeletionTriggerLevel, RetentionUnit,
LegalHoldStatus, REVIEW_INTERVAL_LABELS,
} from '@/lib/sdk/loeschfristen-types'
import { TagInput } from './TagInput'
import { renderTriggerBadge } from './UebersichtTab'
// ---------------------------------------------------------------------------
// Shared type
// ---------------------------------------------------------------------------
export type SetFn = <K extends keyof LoeschfristPolicy>(key: K, val: LoeschfristPolicy[K]) => void
// ---------------------------------------------------------------------------
// Sektion 1: Datenobjekt
// ---------------------------------------------------------------------------
export function DataObjectSection({ policy, set }: { policy: LoeschfristPolicy; set: SetFn }) {
return (
<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
// ---------------------------------------------------------------------------
export function DeletionLogicSection({
policy, pid, set, updateLegalHoldItem, addLegalHold, removeLegalHold,
}: {
policy: LoeschfristPolicy; pid: string; set: SetFn
updateLegalHoldItem: (idx: number, updater: (h: LegalHold) => LegalHold) => void
addLegalHold: (policyId: string) => void
removeLegalHold: (policyId: string, idx: number) => void
}) {
return (
<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>
{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) => updateLegalHoldItem(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) => updateLegalHoldItem(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) => updateLegalHoldItem(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) => updateLegalHoldItem(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
// ---------------------------------------------------------------------------
export function StorageSection({
policy, pid, set, updateStorageLocationItem, addStorageLocation, removeStorageLocation,
}: {
policy: LoeschfristPolicy; pid: string; set: SetFn
updateStorageLocationItem: (idx: number, updater: (s: StorageLocation) => StorageLocation) => void
addStorageLocation: (policyId: string) => void
removeStorageLocation: (policyId: string, idx: number) => void
}) {
return (
<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) => updateStorageLocationItem(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) => updateStorageLocationItem(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) => updateStorageLocationItem(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) => updateStorageLocationItem(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) => updateStorageLocationItem(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
// ---------------------------------------------------------------------------
export function ResponsibilitySection({ policy, set }: { policy: LoeschfristPolicy; set: SetFn }) {
return (
<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
// ---------------------------------------------------------------------------
export function VVTLinkSection({
policy, pid, vvtActivities, updatePolicy,
}: {
policy: LoeschfristPolicy; pid: string; vvtActivities: any[]
updatePolicy: (id: string, updater: (p: LoeschfristPolicy) => LoeschfristPolicy) => void
}) {
return (
<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
// ---------------------------------------------------------------------------
export function ReviewSection({ policy, set }: { policy: LoeschfristPolicy; set: SetFn }) {
return (
<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>
)
}

View File

@@ -0,0 +1,170 @@
'use client'
import React from 'react'
import {
LoeschfristPolicy, LegalHold, StorageLocation,
} from '@/lib/sdk/loeschfristen-types'
import { renderStatusBadge } from './UebersichtTab'
import {
DataObjectSection, DeletionLogicSection, StorageSection,
ResponsibilitySection, VVTLinkSection, ReviewSection,
} from './EditorSections'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface EditorTabProps {
policies: LoeschfristPolicy[]
editingId: string | null
editingPolicy: LoeschfristPolicy | null
vvtActivities: any[]
saving: boolean
setEditingId: (id: string | null) => void
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
updatePolicy: (id: string, updater: (p: LoeschfristPolicy) => LoeschfristPolicy) => void
createNewPolicy: () => void
deletePolicy: (policyId: string) => void
addLegalHold: (policyId: string) => void
removeLegalHold: (policyId: string, idx: number) => void
addStorageLocation: (policyId: string) => void
removeStorageLocation: (policyId: string, idx: number) => void
handleSaveAndClose: () => void
}
// ---------------------------------------------------------------------------
// No-selection view
// ---------------------------------------------------------------------------
function EditorNoSelection({
policies, setEditingId, createNewPolicy,
}: Pick<EditorTabProps, 'policies' | 'setEditingId' | 'createNewPolicy'>) {
return (
<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>
)
}
// ---------------------------------------------------------------------------
// Editor form
// ---------------------------------------------------------------------------
function EditorForm({
policy, vvtActivities, saving, setEditingId, setTab,
updatePolicy, deletePolicy, addLegalHold, removeLegalHold,
addStorageLocation, removeStorageLocation, handleSaveAndClose,
}: Omit<EditorTabProps, 'policies' | 'editingId' | 'editingPolicy' | 'createNewPolicy'> & {
policy: LoeschfristPolicy
}) {
const pid = policy.policyId
const set = <K extends keyof LoeschfristPolicy>(key: K, val: LoeschfristPolicy[K]) => {
updatePolicy(pid, (p) => ({ ...p, [key]: val }))
}
const updateLegalHoldItem = (idx: number, updater: (h: LegalHold) => LegalHold) => {
updatePolicy(pid, (p) => ({
...p, legalHolds: p.legalHolds.map((h, i) => (i === idx ? updater(h) : h)),
}))
}
const updateStorageLocationItem = (idx: number, updater: (s: StorageLocation) => StorageLocation) => {
updatePolicy(pid, (p) => ({
...p, storageLocations: p.storageLocations.map((s, i) => (i === idx ? updater(s) : s)),
}))
}
return (
<div className="space-y-6">
{/* Header with back button */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => setEditingId(null)} className="text-gray-400 hover:text-gray-600 transition">
&larr; Zurueck
</button>
<h2 className="text-lg font-semibold text-gray-900">
{policy.dataObjectName || 'Neue Loeschfrist'}
</h2>
<span className="text-xs text-gray-400 font-mono">{policy.policyId}</span>
</div>
{renderStatusBadge(policy.status)}
</div>
<DataObjectSection policy={policy} set={set} />
<DeletionLogicSection policy={policy} pid={pid} set={set}
updateLegalHoldItem={updateLegalHoldItem} addLegalHold={addLegalHold} removeLegalHold={removeLegalHold} />
<StorageSection policy={policy} pid={pid} set={set}
updateStorageLocationItem={updateStorageLocationItem}
addStorageLocation={addStorageLocation} removeStorageLocation={removeStorageLocation} />
<ResponsibilitySection policy={policy} set={set} />
<VVTLinkSection policy={policy} pid={pid} vvtActivities={vvtActivities} updatePolicy={updatePolicy} />
<ReviewSection policy={policy} set={set} />
{/* 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>
)
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
export function EditorTab(props: EditorTabProps) {
if (!props.editingId || !props.editingPolicy) {
return (
<EditorNoSelection policies={props.policies}
setEditingId={props.setEditingId} createNewPolicy={props.createNewPolicy} />
)
}
return <EditorForm {...props} policy={props.editingPolicy} />
}

View File

@@ -0,0 +1,261 @@
'use client'
import React from 'react'
import { LoeschfristPolicy } from '@/lib/sdk/loeschfristen-types'
import { ComplianceCheckResult } from '@/lib/sdk/loeschfristen-compliance'
import {
exportPoliciesAsJSON, exportPoliciesAsCSV,
generateComplianceSummary, downloadFile,
} from '@/lib/sdk/loeschfristen-export'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ExportTabProps {
policies: LoeschfristPolicy[]
complianceResult: ComplianceCheckResult | null
runCompliance: () => void
setEditingId: (id: string | null) => void
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ExportTab({
policies,
complianceResult,
runCompliance,
setEditingId,
setTab,
}: ExportTabProps) {
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 && (
<ComplianceResultView
complianceResult={complianceResult}
setEditingId={setEditingId}
setTab={setTab}
/>
)}
</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>
)
}
// ---------------------------------------------------------------------------
// Compliance result sub-component
// ---------------------------------------------------------------------------
function ComplianceResultView({
complianceResult,
setEditingId,
setTab,
}: {
complianceResult: ComplianceCheckResult
setEditingId: (id: string | null) => void
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
}) {
return (
<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>
)
}

View File

@@ -0,0 +1,322 @@
'use client'
import React from 'react'
import {
LoeschfristPolicy,
RETENTION_DRIVER_META,
formatRetentionDuration,
getEffectiveDeletionTrigger,
} from '@/lib/sdk/loeschfristen-types'
import {
PROFILING_STEPS, ProfilingAnswer, ProfilingStep,
isStepComplete, getProfilingProgress,
} from '@/lib/sdk/loeschfristen-profiling'
import { renderTriggerBadge } from './UebersichtTab'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface GeneratorTabProps {
profilingStep: number
setProfilingStep: (s: number | ((prev: number) => number)) => void
profilingAnswers: ProfilingAnswer[]
handleProfilingAnswer: (stepIndex: number, questionId: string, value: any) => void
generatedPolicies: LoeschfristPolicy[]
setGeneratedPolicies: (p: LoeschfristPolicy[]) => void
selectedGenerated: Set<string>
setSelectedGenerated: (s: Set<string>) => void
handleGenerate: () => void
adoptGeneratedPolicies: (onlySelected: boolean) => void
}
// ---------------------------------------------------------------------------
// Generated policies preview
// ---------------------------------------------------------------------------
function GeneratedPreview({
generatedPolicies,
selectedGenerated,
setSelectedGenerated,
setGeneratedPolicies,
adoptGeneratedPolicies,
}: Pick<
GeneratorTabProps,
'generatedPolicies' | 'selectedGenerated' | 'setSelectedGenerated' | 'setGeneratedPolicies' | 'adoptGeneratedPolicies'
>) {
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
// ---------------------------------------------------------------------------
function ProfilingWizard({
profilingStep,
setProfilingStep,
profilingAnswers,
handleProfilingAnswer,
handleGenerate,
}: Pick<
GeneratorTabProps,
'profilingStep' | 'setProfilingStep' | 'profilingAnswers' | 'handleProfilingAnswer' | 'handleGenerate'
>) {
const totalSteps = PROFILING_STEPS.length
const progress = getProfilingProgress(profilingAnswers)
const allComplete = PROFILING_STEPS.every((step, idx) =>
isStepComplete(step, profilingAnswers.filter((a) => a.stepIndex === idx)),
)
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>
{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>
)}
{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>
)}
{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) => {
const next = e.target.checked
? [...selectedValues, opt.value]
: 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>
)}
{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: number) => 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: number) => 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>
)
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
export function GeneratorTab(props: GeneratorTabProps) {
if (props.generatedPolicies.length > 0) {
return <GeneratedPreview {...props} />
}
return <ProfilingWizard {...props} />
}

View File

@@ -0,0 +1,60 @@
'use client'
import React, { useState } from 'react'
export 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>
)
}

View File

@@ -0,0 +1,230 @@
'use client'
import React from 'react'
import {
LoeschfristPolicy, PolicyStatus, DeletionTriggerLevel,
STATUS_COLORS, STATUS_LABELS, TRIGGER_COLORS, TRIGGER_LABELS,
RETENTION_DRIVER_META, formatRetentionDuration, isPolicyOverdue,
getActiveLegalHolds, getEffectiveDeletionTrigger,
} from '@/lib/sdk/loeschfristen-types'
// ---------------------------------------------------------------------------
// Badge helpers
// ---------------------------------------------------------------------------
export function 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>
)
}
export function 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>
)
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface UebersichtTabProps {
policies: LoeschfristPolicy[]
filteredPolicies: LoeschfristPolicy[]
stats: { total: number; active: number; draft: number; overdue: number; legalHolds: number }
searchQuery: string
setSearchQuery: (q: string) => void
filter: string
setFilter: (f: string) => void
driverFilter: string
setDriverFilter: (f: string) => void
setTab: (tab: 'uebersicht' | 'editor' | 'generator' | 'export') => void
setEditingId: (id: string | null) => void
createNewPolicy: () => void
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function UebersichtTab({
policies,
filteredPolicies,
stats,
searchQuery,
setSearchQuery,
filter,
setFilter,
driverFilter,
setDriverFilter,
setTab,
setEditingId,
createNewPolicy,
}: UebersichtTabProps) {
return (
<div className="space-y-6">
{/* Stats bar */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ label: 'Gesamt', value: stats.total, color: 'text-gray-900' },
{ label: 'Aktiv', value: stats.active, color: 'text-green-600' },
{ label: 'Entwurf', value: stats.draft, color: 'text-yellow-600' },
{ label: 'Pruefung faellig', value: stats.overdue, color: 'text-red-600' },
{ label: 'Legal Holds aktiv', value: stats.legalHolds, color: 'text-orange-600' },
].map((s) => (
<div key={s.label} className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className={`text-3xl font-bold ${s.color}`}>{s.value}</div>
<div className="text-sm text-gray-500 mt-1">{s.label}</div>
</div>
))}
</div>
{/* Search & filters */}
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-3">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Suche nach Name, ID oder Beschreibung..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-gray-500 font-medium">Status:</span>
{[
{ key: 'all', label: 'Alle' },
{ key: 'active', label: 'Aktiv' },
{ key: 'draft', label: 'Entwurf' },
{ key: 'review', label: 'Pruefung noetig' },
].map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-3 py-1 rounded-lg text-sm font-medium transition ${
filter === f.key
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f.label}
</button>
))}
<span className="text-sm text-gray-500 font-medium ml-4">Aufbewahrungstreiber:</span>
<select
value={driverFilter}
onChange={(e) => setDriverFilter(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle</option>
{Object.entries(RETENTION_DRIVER_META).map(([key, meta]) => (
<option key={key} value={key}>{meta.label}</option>
))}
</select>
</div>
</div>
{/* Policy cards or empty state */}
{filteredPolicies.length === 0 && policies.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="text-gray-400 text-5xl mb-4">&#128203;</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Noch keine Loeschfristen angelegt
</h3>
<p className="text-gray-500 mb-6">
Starten Sie den Generator, um auf Basis Ihres Unternehmensprofils
automatisch passende Loeschfristen zu erstellen, oder legen Sie
manuell eine neue Loeschfrist an.
</p>
<div className="flex justify-center gap-3">
<button
onClick={() => setTab('generator')}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 font-medium transition"
>
Generator starten
</button>
<button
onClick={createNewPolicy}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 rounded-lg px-4 py-2 font-medium transition"
>
Neue Loeschfrist
</button>
</div>
</div>
) : filteredPolicies.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<p className="text-gray-500">Keine Loeschfristen entsprechen den aktuellen Filtern.</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredPolicies.map((p) => {
const trigger = getEffectiveDeletionTrigger(p)
const activeHolds = getActiveLegalHolds(p)
const overdue = isPolicyOverdue(p)
return (
<div
key={p.policyId}
className="bg-white rounded-xl border border-gray-200 p-6 hover:shadow-md transition relative"
>
{activeHolds.length > 0 && (
<span
className="absolute top-3 right-3 text-orange-500"
title={`${activeHolds.length} aktive Legal Hold(s)`}
>
&#9888;
</span>
)}
<div className="text-xs text-gray-400 font-mono mb-1">{p.policyId}</div>
<h3 className="text-base font-semibold text-gray-900 mb-2 truncate">
{p.dataObjectName || 'Ohne Bezeichnung'}
</h3>
<div className="flex flex-wrap gap-1.5 mb-3">
{renderTriggerBadge(trigger)}
<span className="inline-block text-xs font-medium px-2 py-0.5 rounded-full bg-blue-100 text-blue-800">
{formatRetentionDuration(p)}
</span>
{renderStatusBadge(p.status)}
{overdue && (
<span className="inline-block text-xs font-semibold px-2 py-0.5 rounded-full bg-red-100 text-red-700">
Pruefung faellig
</span>
)}
</div>
{p.description && (
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{p.description}</p>
)}
<button
onClick={() => {
setEditingId(p.policyId)
setTab('editor')
}}
className="text-sm text-purple-600 hover:text-purple-800 font-medium"
>
Bearbeiten &rarr;
</button>
</div>
)
})}
</div>
)}
{/* Floating action button */}
{policies.length > 0 && (
<div className="flex justify-end">
<button
onClick={createNewPolicy}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-5 py-2.5 font-medium transition shadow-sm"
>
+ Neue Loeschfrist
</button>
</div>
)}
</div>
)
}