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:
189
admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx
Normal file
189
admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Task, TASK_CATEGORIES, PRIORITY_LABELS, PRIORITY_COLORS,
|
||||
TASK_STATUS_LABELS, TASK_STATUS_COLORS, apiFetch, formatDate,
|
||||
} from './types'
|
||||
import {
|
||||
Skeleton, Modal, Badge, FormLabel, FormInput, FormTextarea,
|
||||
FormSelect, PrimaryButton, SecondaryButton, ErrorState, EmptyState,
|
||||
IconTask, IconPlus, IconCheck, IconCalendar,
|
||||
} from './ui-primitives'
|
||||
|
||||
export function AufgabenTab({
|
||||
assignmentId,
|
||||
addToast,
|
||||
}: {
|
||||
assignmentId: string
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
const [newCategory, setNewCategory] = useState(TASK_CATEGORIES[0])
|
||||
const [newPriority, setNewPriority] = useState('medium')
|
||||
const [newDueDate, setNewDueDate] = useState('')
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = statusFilter !== 'all' ? `?status=${statusFilter}` : ''
|
||||
const data = await apiFetch<Task[]>(`/api/sdk/v1/dsb/assignments/${assignmentId}/tasks${params}`)
|
||||
setTasks(data)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Aufgaben')
|
||||
} finally { setLoading(false) }
|
||||
}, [assignmentId, statusFilter])
|
||||
|
||||
useEffect(() => { fetchTasks() }, [fetchTasks])
|
||||
|
||||
const handleCreateTask = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch<Task>(`/api/sdk/v1/dsb/assignments/${assignmentId}/tasks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: newTitle, description: newDesc, category: newCategory,
|
||||
priority: newPriority, due_date: newDueDate || null,
|
||||
}),
|
||||
})
|
||||
addToast('Aufgabe erstellt')
|
||||
setShowModal(false)
|
||||
setNewTitle(''); setNewDesc(''); setNewCategory(TASK_CATEGORIES[0])
|
||||
setNewPriority('medium'); setNewDueDate('')
|
||||
fetchTasks()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleCompleteTask = async (taskId: string) => {
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/tasks/${taskId}/complete`, { method: 'POST' })
|
||||
addToast('Aufgabe abgeschlossen')
|
||||
fetchTasks()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const statusFilters = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: 'open', label: 'Offen' },
|
||||
{ value: 'in_progress', label: 'In Bearbeitung' },
|
||||
{ value: 'completed', label: 'Erledigt' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{statusFilters.map((f) => (
|
||||
<button key={f.value} onClick={() => setStatusFilter(f.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
statusFilter === f.value ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
||||
<IconPlus /> Neue Aufgabe
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-lg" />)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={fetchTasks} />
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyState icon={<IconTask className="w-7 h-7" />} title="Keine Aufgaben"
|
||||
description="Erstellen Sie eine neue Aufgabe um zu beginnen." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className={`font-medium ${task.status === 'completed' ? 'line-through text-gray-400' : 'text-gray-900'}`}>
|
||||
{task.title}
|
||||
</h4>
|
||||
<Badge label={task.category} className="bg-purple-50 text-purple-600 border-purple-200" />
|
||||
<Badge label={PRIORITY_LABELS[task.priority] || task.priority}
|
||||
className={PRIORITY_COLORS[task.priority] || 'bg-gray-100 text-gray-500'} />
|
||||
<Badge label={TASK_STATUS_LABELS[task.status] || task.status}
|
||||
className={TASK_STATUS_COLORS[task.status] || 'bg-gray-100 text-gray-500'} />
|
||||
</div>
|
||||
{task.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{task.description}</p>}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
||||
{task.due_date && (
|
||||
<span className="flex items-center gap-1"><IconCalendar className="w-3 h-3" />Frist: {formatDate(task.due_date)}</span>
|
||||
)}
|
||||
<span>Erstellt: {formatDate(task.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{task.status !== 'completed' && task.status !== 'cancelled' && (
|
||||
<button onClick={() => handleCompleteTask(task.id)} title="Aufgabe abschliessen"
|
||||
className="p-2 rounded-lg text-green-600 hover:bg-green-50 transition-colors flex-shrink-0">
|
||||
<IconCheck className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create task modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title="Neue Aufgabe erstellen">
|
||||
<form onSubmit={handleCreateTask} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="task-title">Titel *</FormLabel>
|
||||
<FormInput id="task-title" value={newTitle} onChange={setNewTitle} placeholder="Aufgabentitel" required />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="task-desc">Beschreibung</FormLabel>
|
||||
<FormTextarea id="task-desc" value={newDesc} onChange={setNewDesc} placeholder="Beschreibung der Aufgabe..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="task-cat">Kategorie</FormLabel>
|
||||
<FormSelect id="task-cat" value={newCategory} onChange={setNewCategory}
|
||||
options={TASK_CATEGORIES.map((c) => ({ value: c, label: c }))} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="task-prio">Prioritaet</FormLabel>
|
||||
<FormSelect id="task-prio" value={newPriority} onChange={setNewPriority}
|
||||
options={[
|
||||
{ value: 'urgent', label: 'Dringend' }, { value: 'high', label: 'Hoch' },
|
||||
{ value: 'medium', label: 'Mittel' }, { value: 'low', label: 'Niedrig' },
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="task-due">Faelligkeitsdatum</FormLabel>
|
||||
<FormInput id="task-due" type="date" value={newDueDate} onChange={setNewDueDate} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
||||
<PrimaryButton type="submit" disabled={saving || !newTitle.trim()}>
|
||||
{saving ? 'Erstelle...' : 'Aufgabe erstellen'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx
Normal file
126
admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, ASSIGNMENT_STATUS_COLORS, formatDate } from './types'
|
||||
import {
|
||||
Badge, ComplianceBar, HoursBar,
|
||||
IconBack, IconTask, IconClock, IconMail, IconSettings, IconShield,
|
||||
} from './ui-primitives'
|
||||
import { AufgabenTab } from './AufgabenTab'
|
||||
import { ZeiterfassungTab } from './ZeiterfassungTab'
|
||||
import { KommunikationTab } from './KommunikationTab'
|
||||
import { EinstellungenTab } from './EinstellungenTab'
|
||||
|
||||
type DetailTab = 'aufgaben' | 'zeit' | 'kommunikation' | 'einstellungen'
|
||||
|
||||
export function DetailView({
|
||||
assignment,
|
||||
onBack,
|
||||
onUpdate,
|
||||
addToast,
|
||||
}: {
|
||||
assignment: AssignmentOverview
|
||||
onBack: () => void
|
||||
onUpdate: () => void
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('aufgaben')
|
||||
|
||||
const tabs: { id: DetailTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'aufgaben', label: 'Aufgaben', icon: <IconTask className="w-4 h-4" /> },
|
||||
{ id: 'zeit', label: 'Zeiterfassung', icon: <IconClock className="w-4 h-4" /> },
|
||||
{ id: 'kommunikation', label: 'Kommunikation', icon: <IconMail className="w-4 h-4" /> },
|
||||
{ id: 'einstellungen', label: 'Einstellungen', icon: <IconSettings className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back + Header */}
|
||||
<div>
|
||||
<button onClick={onBack}
|
||||
className="flex items-center gap-2 text-sm text-purple-600 hover:text-purple-800 font-medium mb-4 transition-colors">
|
||||
<IconBack className="w-4 h-4" /> Zurueck zur Uebersicht
|
||||
</button>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
<IconShield className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">{assignment.tenant_name}</h2>
|
||||
<p className="text-sm text-gray-400 font-mono">{assignment.tenant_slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
label={ASSIGNMENT_STATUS_LABELS[assignment.status] || assignment.status}
|
||||
className={ASSIGNMENT_STATUS_COLORS[assignment.status] || 'bg-gray-100 text-gray-600'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 pt-5 border-t border-gray-100">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Vertragsbeginn</p>
|
||||
<p className="text-sm font-medium text-gray-700">{formatDate(assignment.contract_start)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Vertragsende</p>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{assignment.contract_end ? formatDate(assignment.contract_end) : 'Unbefristet'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Compliance-Score</p>
|
||||
<div className="mt-1"><ComplianceBar score={assignment.compliance_score} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Stunden diesen Monat</p>
|
||||
<div className="mt-1"><HoursBar used={assignment.hours_this_month} budget={assignment.hours_budget} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assignment.notes && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-400 mb-1">Anmerkungen</p>
|
||||
<p className="text-sm text-gray-600">{assignment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-0 -mb-px overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-700'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === 'aufgaben' && <AufgabenTab assignmentId={assignment.id} addToast={addToast} />}
|
||||
{activeTab === 'zeit' && (
|
||||
<ZeiterfassungTab assignmentId={assignment.id} monthlyBudget={assignment.monthly_hours_budget} addToast={addToast} />
|
||||
)}
|
||||
{activeTab === 'kommunikation' && <KommunikationTab assignmentId={assignment.id} addToast={addToast} />}
|
||||
{activeTab === 'einstellungen' && (
|
||||
<EinstellungenTab assignment={assignment} onUpdate={onUpdate} addToast={addToast} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, apiFetch } from './types'
|
||||
import { FormLabel, FormInput, FormTextarea, PrimaryButton } from './ui-primitives'
|
||||
|
||||
export function EinstellungenTab({
|
||||
assignment,
|
||||
onUpdate,
|
||||
addToast,
|
||||
}: {
|
||||
assignment: AssignmentOverview
|
||||
onUpdate: () => void
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [status, setStatus] = useState(assignment.status)
|
||||
const [budget, setBudget] = useState(String(assignment.monthly_hours_budget))
|
||||
const [notes, setNotes] = useState(assignment.notes || '')
|
||||
const [contractStart, setContractStart] = useState(assignment.contract_start?.slice(0, 10) || '')
|
||||
const [contractEnd, setContractEnd] = useState(assignment.contract_end?.slice(0, 10) || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/assignments/${assignment.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
monthly_hours_budget: parseFloat(budget) || 0,
|
||||
notes,
|
||||
contract_start: contractStart || null,
|
||||
contract_end: contractEnd || null,
|
||||
}),
|
||||
})
|
||||
addToast('Einstellungen gespeichert')
|
||||
onUpdate()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler beim Speichern', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Status */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Status</h4>
|
||||
<div className="flex gap-2">
|
||||
{(['active', 'paused', 'terminated'] as const).map((s) => (
|
||||
<button key={s} onClick={() => setStatus(s)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
status === s
|
||||
? s === 'active' ? 'bg-green-100 text-green-700 border-green-300'
|
||||
: s === 'paused' ? 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||||
: 'bg-red-100 text-red-700 border-red-300'
|
||||
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
|
||||
}`}>
|
||||
{ASSIGNMENT_STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contract period */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Vertragszeitraum</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="s-start">Vertragsbeginn</FormLabel>
|
||||
<FormInput id="s-start" type="date" value={contractStart} onChange={setContractStart} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="s-end">Vertragsende</FormLabel>
|
||||
<FormInput id="s-end" type="date" value={contractEnd} onChange={setContractEnd} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Monatliches Stundenbudget</h4>
|
||||
<div className="max-w-xs">
|
||||
<FormInput type="number" value={budget} onChange={setBudget} min={0} max={999} step={1} />
|
||||
<p className="text-xs text-gray-400 mt-1">Stunden pro Monat</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Anmerkungen</h4>
|
||||
<FormTextarea value={notes} onChange={setNotes} placeholder="Interne Anmerkungen zum Mandat..." rows={4} />
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Speichere...' : 'Einstellungen speichern'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Communication, COMM_CHANNELS, apiFetch, formatDateTime } from './types'
|
||||
import {
|
||||
Skeleton, Modal, Badge, FormLabel, FormInput, FormTextarea,
|
||||
FormSelect, PrimaryButton, SecondaryButton, ErrorState, EmptyState,
|
||||
IconMail, IconPlus, IconInbound, IconOutbound,
|
||||
} from './ui-primitives'
|
||||
|
||||
const CHANNEL_COLORS: Record<string, string> = {
|
||||
'E-Mail': 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
'Telefon': 'bg-green-100 text-green-700 border-green-200',
|
||||
'Besprechung': 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
'Portal': 'bg-indigo-100 text-indigo-700 border-indigo-200',
|
||||
'Brief': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
|
||||
export function KommunikationTab({
|
||||
assignmentId,
|
||||
addToast,
|
||||
}: {
|
||||
assignmentId: string
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [comms, setComms] = useState<Communication[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [formDirection, setFormDirection] = useState('outbound')
|
||||
const [formChannel, setFormChannel] = useState(COMM_CHANNELS[0])
|
||||
const [formSubject, setFormSubject] = useState('')
|
||||
const [formContent, setFormContent] = useState('')
|
||||
const [formParticipants, setFormParticipants] = useState('')
|
||||
|
||||
const fetchComms = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await apiFetch<Communication[]>(
|
||||
`/api/sdk/v1/dsb/assignments/${assignmentId}/communications`,
|
||||
)
|
||||
setComms(data)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Kommunikation')
|
||||
} finally { setLoading(false) }
|
||||
}, [assignmentId])
|
||||
|
||||
useEffect(() => { fetchComms() }, [fetchComms])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/communications`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
direction: formDirection, channel: formChannel,
|
||||
subject: formSubject, content: formContent, participants: formParticipants,
|
||||
}),
|
||||
})
|
||||
addToast('Kommunikation erfasst'); setShowModal(false)
|
||||
setFormDirection('outbound'); setFormChannel(COMM_CHANNELS[0])
|
||||
setFormSubject(''); setFormContent(''); setFormParticipants('')
|
||||
fetchComms()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-500">Kommunikations-Protokoll</p>
|
||||
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
||||
<IconPlus /> Kommunikation erfassen
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-20 rounded-lg" />)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={fetchComms} />
|
||||
) : comms.length === 0 ? (
|
||||
<EmptyState icon={<IconMail className="w-7 h-7" />} title="Keine Kommunikation"
|
||||
description="Erfassen Sie die erste Kommunikation mit dem Mandanten." />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comms.map((comm) => (
|
||||
<div key={comm.id} className="bg-white border border-gray-200 rounded-xl p-4 hover:border-purple-200 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
comm.direction === 'inbound' ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600'
|
||||
}`}>
|
||||
{comm.direction === 'inbound' ? <IconInbound className="w-4 h-4" /> : <IconOutbound className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-900 text-sm">{comm.subject}</span>
|
||||
<Badge label={comm.channel}
|
||||
className={CHANNEL_COLORS[comm.channel] || 'bg-gray-100 text-gray-600 border-gray-200'} />
|
||||
<span className="text-xs text-gray-400">
|
||||
{comm.direction === 'inbound' ? 'Eingehend' : 'Ausgehend'}
|
||||
</span>
|
||||
</div>
|
||||
{comm.content && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{comm.content}</p>}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
||||
<span>{formatDateTime(comm.created_at)}</span>
|
||||
{comm.participants && <span>Teilnehmer: {comm.participants}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create communication modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title="Kommunikation erfassen">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-dir">Richtung</FormLabel>
|
||||
<FormSelect id="comm-dir" value={formDirection} onChange={setFormDirection}
|
||||
options={[{ value: 'outbound', label: 'Ausgehend' }, { value: 'inbound', label: 'Eingehend' }]} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-ch">Kanal</FormLabel>
|
||||
<FormSelect id="comm-ch" value={formChannel} onChange={setFormChannel}
|
||||
options={COMM_CHANNELS.map((c) => ({ value: c, label: c }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-subj">Betreff *</FormLabel>
|
||||
<FormInput id="comm-subj" value={formSubject} onChange={setFormSubject}
|
||||
placeholder="Betreff der Kommunikation" required />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-content">Inhalt</FormLabel>
|
||||
<FormTextarea id="comm-content" value={formContent} onChange={setFormContent}
|
||||
placeholder="Inhalt / Zusammenfassung..." rows={4} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="comm-parts">Teilnehmer</FormLabel>
|
||||
<FormInput id="comm-parts" value={formParticipants} onChange={setFormParticipants}
|
||||
placeholder="z.B. Herr Mueller, Frau Schmidt" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
||||
<PrimaryButton type="submit" disabled={saving || !formSubject.trim()}>
|
||||
{saving ? 'Speichere...' : 'Kommunikation erfassen'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, ASSIGNMENT_STATUS_COLORS, formatDate } from './types'
|
||||
import { Badge, ComplianceBar, HoursBar, IconTask, IconCalendar } from './ui-primitives'
|
||||
|
||||
export function MandantCard({
|
||||
assignment,
|
||||
onClick,
|
||||
}: {
|
||||
assignment: AssignmentOverview
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="bg-white rounded-xl border border-gray-200 p-5 hover:border-purple-400 hover:shadow-lg cursor-pointer transition-all group"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate group-hover:text-purple-700 transition-colors">
|
||||
{assignment.tenant_name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 font-mono">{assignment.tenant_slug}</p>
|
||||
</div>
|
||||
<Badge
|
||||
label={ASSIGNMENT_STATUS_LABELS[assignment.status] || assignment.status}
|
||||
className={ASSIGNMENT_STATUS_COLORS[assignment.status] || 'bg-gray-100 text-gray-600'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compliance Score */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Compliance-Score</span>
|
||||
</div>
|
||||
<ComplianceBar score={assignment.compliance_score} />
|
||||
</div>
|
||||
|
||||
{/* Hours */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Stunden diesen Monat</span>
|
||||
</div>
|
||||
<HoursBar used={assignment.hours_this_month} budget={assignment.hours_budget} />
|
||||
</div>
|
||||
|
||||
{/* Footer: Tasks */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-600">
|
||||
<IconTask className="w-4 h-4" />
|
||||
<span>{assignment.open_task_count} offene Aufgaben</span>
|
||||
</div>
|
||||
{assignment.urgent_task_count > 0 && (
|
||||
<Badge label={`${assignment.urgent_task_count} dringend`} className="bg-red-100 text-red-700 border-red-200" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next deadline */}
|
||||
{assignment.next_deadline && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-gray-400">
|
||||
<IconCalendar className="w-3 h-3" />
|
||||
<span>Naechste Frist: {formatDate(assignment.next_deadline)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
HourEntry, HoursSummary, HOUR_CATEGORIES,
|
||||
apiFetch, formatDate, currentMonth, monthLabel, prevMonth, nextMonth,
|
||||
} from './types'
|
||||
import {
|
||||
Skeleton, Modal, Badge, HoursBar, FormLabel, FormInput,
|
||||
FormTextarea, FormSelect, PrimaryButton, SecondaryButton,
|
||||
ErrorState, EmptyState, IconClock, IconPlus,
|
||||
} from './ui-primitives'
|
||||
|
||||
export function ZeiterfassungTab({
|
||||
assignmentId,
|
||||
monthlyBudget,
|
||||
addToast,
|
||||
}: {
|
||||
assignmentId: string
|
||||
monthlyBudget: number
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [hours, setHours] = useState<HourEntry[]>([])
|
||||
const [summary, setSummary] = useState<HoursSummary | null>(null)
|
||||
const [month, setMonth] = useState(currentMonth())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [formDate, setFormDate] = useState(new Date().toISOString().slice(0, 10))
|
||||
const [formHours, setFormHours] = useState('1')
|
||||
const [formCategory, setFormCategory] = useState(HOUR_CATEGORIES[0])
|
||||
const [formDesc, setFormDesc] = useState('')
|
||||
const [formBillable, setFormBillable] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const [hoursData, summaryData] = await Promise.all([
|
||||
apiFetch<HourEntry[]>(`/api/sdk/v1/dsb/assignments/${assignmentId}/hours?month=${month}`),
|
||||
apiFetch<HoursSummary>(`/api/sdk/v1/dsb/assignments/${assignmentId}/hours/summary?month=${month}`),
|
||||
])
|
||||
setHours(hoursData); setSummary(summaryData)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Zeiterfassung')
|
||||
} finally { setLoading(false) }
|
||||
}, [assignmentId, month])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const handleLogHours = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/assignments/${assignmentId}/hours`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
date: formDate, hours: parseFloat(formHours),
|
||||
category: formCategory, description: formDesc, billable: formBillable,
|
||||
}),
|
||||
})
|
||||
addToast('Stunden erfasst'); setShowModal(false)
|
||||
setFormDate(new Date().toISOString().slice(0, 10))
|
||||
setFormHours('1'); setFormCategory(HOUR_CATEGORIES[0])
|
||||
setFormDesc(''); setFormBillable(true); fetchData()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const maxCatHours = summary ? Math.max(...Object.values(summary.by_category), 1) : 1
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setMonth(prevMonth(month))}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-gray-700 min-w-[140px] text-center">{monthLabel(month)}</span>
|
||||
<button onClick={() => setMonth(nextMonth(month))}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
||||
<IconPlus /> Stunden erfassen
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-24 rounded-lg" />
|
||||
<Skeleton className="h-40 rounded-lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={fetchData} />
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards */}
|
||||
{summary && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="bg-purple-50 rounded-xl p-4 border border-purple-200">
|
||||
<p className="text-xs text-purple-600 font-medium">Gesamt-Stunden</p>
|
||||
<p className="text-2xl font-bold text-purple-900 mt-1">{summary.total_hours}h</p>
|
||||
<div className="mt-2"><HoursBar used={summary.total_hours} budget={monthlyBudget} /></div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-xl p-4 border border-green-200">
|
||||
<p className="text-xs text-green-600 font-medium">Abrechnungsfaehig</p>
|
||||
<p className="text-2xl font-bold text-green-900 mt-1">{summary.billable_hours}h</p>
|
||||
<p className="text-xs text-green-500 mt-1">
|
||||
{summary.total_hours > 0
|
||||
? `${Math.round((summary.billable_hours / summary.total_hours) * 100)}% der Gesamtstunden`
|
||||
: 'Keine Stunden erfasst'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||
<p className="text-xs text-gray-500 font-medium">Budget verbleibend</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">{Math.max(monthlyBudget - summary.total_hours, 0)}h</p>
|
||||
<p className="text-xs text-gray-400 mt-1">von {monthlyBudget}h Monatsbudget</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hours by category */}
|
||||
{summary && Object.keys(summary.by_category).length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Stunden nach Kategorie</h4>
|
||||
<div className="space-y-2.5">
|
||||
{Object.entries(summary.by_category).sort(([, a], [, b]) => b - a).map(([cat, h]) => (
|
||||
<div key={cat} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 min-w-[120px] truncate">{cat}</span>
|
||||
<div className="flex-1 h-5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-400 rounded-full transition-all"
|
||||
style={{ width: `${(h / maxCatHours) * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-700 min-w-[40px] text-right">{h}h</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hours table */}
|
||||
{hours.length === 0 ? (
|
||||
<EmptyState icon={<IconClock className="w-7 h-7" />} title="Keine Stunden erfasst"
|
||||
description={`Fuer ${monthLabel(month)} wurden noch keine Stunden erfasst.`} />
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Datum</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Stunden</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Kategorie</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Beschreibung</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase">Abrechenbar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{hours.map((entry) => (
|
||||
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3 text-gray-700 whitespace-nowrap">{formatDate(entry.date)}</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{entry.hours}h</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge label={entry.category} className="bg-purple-50 text-purple-600 border-purple-200" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 max-w-xs truncate">{entry.description || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{entry.billable
|
||||
? <span className="text-green-600 font-medium">Ja</span>
|
||||
: <span className="text-gray-400">Nein</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log hours modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title="Stunden erfassen">
|
||||
<form onSubmit={handleLogHours} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="h-date">Datum *</FormLabel>
|
||||
<FormInput id="h-date" type="date" value={formDate} onChange={setFormDate} required />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="h-hours">Stunden *</FormLabel>
|
||||
<FormInput id="h-hours" type="number" value={formHours} onChange={setFormHours}
|
||||
min={0.25} max={24} step={0.25} required />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="h-cat">Kategorie</FormLabel>
|
||||
<FormSelect id="h-cat" value={formCategory} onChange={setFormCategory}
|
||||
options={HOUR_CATEGORIES.map((c) => ({ value: c, label: c }))} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="h-desc">Beschreibung</FormLabel>
|
||||
<FormTextarea id="h-desc" value={formDesc} onChange={setFormDesc} placeholder="Was wurde gemacht..." />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="h-billable" type="checkbox" checked={formBillable}
|
||||
onChange={(e) => setFormBillable(e.target.checked)}
|
||||
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<label htmlFor="h-billable" className="text-sm text-gray-700">Abrechnungsfaehig</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
||||
<PrimaryButton type="submit" disabled={saving}>
|
||||
{saving ? 'Erfasse...' : 'Stunden erfassen'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
199
admin-compliance/app/sdk/dsb-portal/_components/types.ts
Normal file
199
admin-compliance/app/sdk/dsb-portal/_components/types.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface AssignmentOverview {
|
||||
id: string
|
||||
dsb_user_id: string
|
||||
tenant_id: string
|
||||
tenant_name: string
|
||||
tenant_slug: string
|
||||
status: string
|
||||
contract_start: string
|
||||
contract_end: string | null
|
||||
monthly_hours_budget: number
|
||||
notes: string
|
||||
compliance_score: number
|
||||
hours_this_month: number
|
||||
hours_budget: number
|
||||
open_task_count: number
|
||||
urgent_task_count: number
|
||||
next_deadline: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DSBDashboard {
|
||||
assignments: AssignmentOverview[]
|
||||
total_assignments: number
|
||||
active_assignments: number
|
||||
total_hours_this_month: number
|
||||
open_tasks: number
|
||||
urgent_tasks: number
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface HourEntry {
|
||||
id: string
|
||||
assignment_id: string
|
||||
date: string
|
||||
hours: number
|
||||
category: string
|
||||
description: string
|
||||
billable: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
assignment_id: string
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
priority: string
|
||||
status: string
|
||||
due_date: string | null
|
||||
completed_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Communication {
|
||||
id: string
|
||||
assignment_id: string
|
||||
direction: string
|
||||
channel: string
|
||||
subject: string
|
||||
content: string
|
||||
participants: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface HoursSummary {
|
||||
total_hours: number
|
||||
billable_hours: number
|
||||
by_category: Record<string, number>
|
||||
period: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const DSB_USER_ID = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
export const TASK_CATEGORIES = [
|
||||
'DSFA-Pruefung', 'Betroffenenanfrage', 'Vorfall-Pruefung',
|
||||
'Audit-Vorbereitung', 'Richtlinien-Pruefung', 'Schulung',
|
||||
'Beratung', 'Sonstiges',
|
||||
]
|
||||
|
||||
export const HOUR_CATEGORIES = [
|
||||
'DSFA-Pruefung', 'Beratung', 'Audit', 'Schulung',
|
||||
'Vorfallreaktion', 'Dokumentation', 'Besprechung', 'Sonstiges',
|
||||
]
|
||||
|
||||
export const COMM_CHANNELS = ['E-Mail', 'Telefon', 'Besprechung', 'Portal', 'Brief']
|
||||
|
||||
export const PRIORITY_LABELS: Record<string, string> = {
|
||||
urgent: 'Dringend', high: 'Hoch', medium: 'Mittel', low: 'Niedrig',
|
||||
}
|
||||
|
||||
export const PRIORITY_COLORS: Record<string, string> = {
|
||||
urgent: 'bg-red-100 text-red-700 border-red-200',
|
||||
high: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
medium: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
low: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
}
|
||||
|
||||
export const TASK_STATUS_LABELS: Record<string, string> = {
|
||||
open: 'Offen', in_progress: 'In Bearbeitung', waiting: 'Wartend',
|
||||
completed: 'Erledigt', cancelled: 'Abgebrochen',
|
||||
}
|
||||
|
||||
export const TASK_STATUS_COLORS: Record<string, string> = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
waiting: 'bg-orange-100 text-orange-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
cancelled: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
export const ASSIGNMENT_STATUS_COLORS: Record<string, string> = {
|
||||
active: 'bg-green-100 text-green-700 border-green-300',
|
||||
paused: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
terminated: 'bg-red-100 text-red-700 border-red-300',
|
||||
}
|
||||
|
||||
export const ASSIGNMENT_STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Aktiv', paused: 'Pausiert', terminated: 'Beendet',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': DSB_USER_ID,
|
||||
...(options?.headers || {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`API Error ${res.status}: ${text || res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DATE HELPERS
|
||||
// =============================================================================
|
||||
|
||||
export function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
})
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
} catch { return dateStr }
|
||||
}
|
||||
|
||||
export function currentMonth(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function monthLabel(ym: string): string {
|
||||
const [y, m] = ym.split('-')
|
||||
const months = [
|
||||
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
return `${months[parseInt(m, 10) - 1]} ${y}`
|
||||
}
|
||||
|
||||
export function prevMonth(ym: string): string {
|
||||
const [y, m] = ym.split('-').map(Number)
|
||||
if (m === 1) return `${y - 1}-12`
|
||||
return `${y}-${String(m - 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function nextMonth(ym: string): string {
|
||||
const [y, m] = ym.split('-').map(Number)
|
||||
if (m === 12) return `${y + 1}-01`
|
||||
return `${y}-${String(m + 1).padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TOAST
|
||||
// =============================================================================
|
||||
|
||||
export interface ToastMessage {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
}
|
||||
|
||||
let toastIdCounter = 0
|
||||
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
||||
|
||||
const addToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
|
||||
const id = ++toastIdCounter
|
||||
setToasts((prev) => [...prev, { id, message, type }])
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, 3500)
|
||||
}, [])
|
||||
|
||||
return { toasts, addToast }
|
||||
}
|
||||
|
||||
export function ToastContainer({ toasts }: { toasts: ToastMessage[] }) {
|
||||
if (toasts.length === 0) return null
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`px-5 py-3 rounded-lg shadow-lg text-sm font-medium text-white transition-all animate-slide-in ${
|
||||
t.type === 'success' ? 'bg-green-600' : 'bg-red-600'
|
||||
}`}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SKELETON
|
||||
// =============================================================================
|
||||
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={`animate-pulse bg-gray-200 rounded ${className}`} />
|
||||
}
|
||||
|
||||
export function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-28 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-64 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODAL
|
||||
// =============================================================================
|
||||
|
||||
export function Modal({
|
||||
open, onClose, title, children, maxWidth = 'max-w-lg',
|
||||
}: {
|
||||
open: boolean; onClose: () => void; title: string
|
||||
children: React.ReactNode; maxWidth?: string
|
||||
}) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handleEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div ref={overlayRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === overlayRef.current) onClose() }}>
|
||||
<div className={`bg-white rounded-2xl shadow-2xl w-full ${maxWidth} mx-4 overflow-hidden`}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 bg-purple-50">
|
||||
<h3 className="text-lg font-semibold text-purple-900">{title}</h3>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-purple-100 transition-colors text-purple-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD, PROGRESS BARS, BADGE
|
||||
// =============================================================================
|
||||
|
||||
export function StatCard({
|
||||
title, value, icon, accent = false,
|
||||
}: {
|
||||
title: string; value: string | number; icon: React.ReactNode; accent?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-5 transition-all ${
|
||||
accent ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200 hover:border-purple-300'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
accent ? 'bg-red-100 text-red-600' : 'bg-purple-100 text-purple-600'
|
||||
}`}>{icon}</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{title}</p>
|
||||
<p className={`text-2xl font-bold ${accent ? 'text-red-700' : 'text-gray-900'}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComplianceBar({ score }: { score: number }) {
|
||||
const color = score < 40 ? 'bg-red-500' : score < 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
const textColor = score < 40 ? 'text-red-700' : score < 70 ? 'text-yellow-700' : 'text-green-700'
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${Math.min(score, 100)}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs font-semibold min-w-[36px] text-right ${textColor}`}>{score}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HoursBar({ used, budget }: { used: number; budget: number }) {
|
||||
const pct = budget > 0 ? Math.min((used / budget) * 100, 100) : 0
|
||||
const over = used > budget
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${over ? 'bg-red-500' : 'bg-purple-500'}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs font-medium min-w-[60px] text-right ${over ? 'text-red-600' : 'text-gray-600'}`}>
|
||||
{used}h / {budget}h
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Badge({ label, className = '' }: { label: string; className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full border ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FORM COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
export function FormLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
||||
return <label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700 mb-1">{children}</label>
|
||||
}
|
||||
|
||||
export function FormInput({
|
||||
id, type = 'text', value, onChange, placeholder, required, min, max, step,
|
||||
}: {
|
||||
id?: string; type?: string; value: string | number; onChange: (val: string) => void
|
||||
placeholder?: string; required?: boolean; min?: string | number; max?: string | number; step?: string | number
|
||||
}) {
|
||||
return (
|
||||
<input id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder} required={required} min={min} max={max} step={step}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
||||
)
|
||||
}
|
||||
|
||||
export function FormTextarea({
|
||||
id, value, onChange, placeholder, rows = 3,
|
||||
}: { id?: string; value: string; onChange: (val: string) => void; placeholder?: string; rows?: number }) {
|
||||
return (
|
||||
<textarea id={id} value={value} onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder} rows={rows}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none" />
|
||||
)
|
||||
}
|
||||
|
||||
export function FormSelect({
|
||||
id, value, onChange, options, placeholder,
|
||||
}: {
|
||||
id?: string; value: string; onChange: (val: string) => void
|
||||
options: { value: string; label: string }[]; placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<select id={id} value={value} onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 bg-white">
|
||||
{placeholder && <option value="" disabled>{placeholder}</option>}
|
||||
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
onClick, disabled, children, type = 'button', className = '',
|
||||
}: {
|
||||
onClick?: () => void; disabled?: boolean; children: React.ReactNode
|
||||
type?: 'button' | 'submit'; className?: string
|
||||
}) {
|
||||
return (
|
||||
<button type={type} onClick={onClick} disabled={disabled}
|
||||
className={`px-4 py-2 rounded-lg bg-purple-600 text-white text-sm font-medium hover:bg-purple-700 focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SecondaryButton({
|
||||
onClick, disabled, children, type = 'button', className = '',
|
||||
}: {
|
||||
onClick?: () => void; disabled?: boolean; children: React.ReactNode
|
||||
type?: 'button' | 'submit'; className?: string
|
||||
}) {
|
||||
return (
|
||||
<button type={type} onClick={onClick} disabled={disabled}
|
||||
className={`px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${className}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ERROR / EMPTY STATES
|
||||
// =============================================================================
|
||||
|
||||
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-700 font-medium mb-1">Fehler beim Laden</p>
|
||||
<p className="text-sm text-gray-500 mb-4 max-w-md">{message}</p>
|
||||
<PrimaryButton onClick={onRetry}>Erneut versuchen</PrimaryButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-purple-50 flex items-center justify-center mb-3 text-purple-400">{icon}</div>
|
||||
<p className="text-gray-700 font-medium">{title}</p>
|
||||
<p className="text-sm text-gray-400 mt-1">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ICONS
|
||||
// =============================================================================
|
||||
|
||||
export function IconUsers({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconClock({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconTask({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconAlert({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconBack({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconPlus({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconCheck({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconMail({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconSettings({ className = 'w-5 h-5' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconRefresh({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconInbound({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconOutbound({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconShield({ className = 'w-6 h-6' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function IconCalendar({ className = 'w-4 h-4' }: { className?: string }) {
|
||||
return (<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
170
admin-compliance/app/sdk/loeschfristen/_components/EditorTab.tsx
Normal file
170
admin-compliance/app/sdk/loeschfristen/_components/EditorTab.tsx
Normal 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">
|
||||
← 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} />
|
||||
}
|
||||
261
admin-compliance/app/sdk/loeschfristen/_components/ExportTab.tsx
Normal file
261
admin-compliance/app/sdk/loeschfristen/_components/ExportTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">📋</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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user