Files
breakpilot-compliance/admin-compliance/app/sdk/process-tasks/page.tsx
Benjamin Admin 49ce417428
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 32s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 2s
feat: add compliance modules 2-5 (dashboard, security templates, process manager, evidence collector)
Module 2: Extended Compliance Dashboard with roadmap, module-status, next-actions, snapshots, score-history
Module 3: 7 German security document templates (IT-Sicherheitskonzept, Datenschutz, Backup, Logging, Incident-Response, Zugriff, Risikomanagement)
Module 4: Compliance Process Manager with CRUD, complete/skip/seed, ~50 seed tasks, 3-tab UI
Module 5: Evidence Collector Extended with automated checks, control-mapping, coverage report, 4-tab UI

Also includes: canonical control library enhancements (verification method, categories, dedup), control generator improvements, RAG client extensions

52 tests pass, frontend builds clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:03:04 +01:00

1384 lines
52 KiB
TypeScript

'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { useSDK } from '@/lib/sdk'
// =============================================================================
// Types
// =============================================================================
interface ProcessTask {
id: string
tenant_id: string
project_id: string | null
task_code: string
title: string
description: string | null
category: string
priority: string
frequency: string
assigned_to: string | null
responsible_team: string | null
linked_control_ids: string[]
linked_module: string | null
last_completed_at: string | null
next_due_date: string | null
due_reminder_days: number
status: string
completion_date: string | null
completion_result: string | null
completion_evidence_id: string | null
follow_up_actions: string[]
is_seed: boolean
notes: string | null
tags: string[]
created_at: string
updated_at: string
}
interface TaskStats {
total: number
by_status: Record<string, number>
by_category: Record<string, number>
overdue_count: number
due_7_days: number
due_14_days: number
due_30_days: number
}
interface TaskFormData {
task_code: string
title: string
description: string
category: string
priority: string
frequency: string
assigned_to: string
responsible_team: string
linked_module: string
next_due_date: string
due_reminder_days: number
notes: string
}
interface CompleteFormData {
completed_by: string
result: string
notes: string
}
interface HistoryEntry {
id: string
task_id: string
completed_by: string | null
completed_at: string
result: string | null
evidence_id: string | null
notes: string | null
status: string
}
const EMPTY_FORM: TaskFormData = {
task_code: '',
title: '',
description: '',
category: 'dsgvo',
priority: 'medium',
frequency: 'yearly',
assigned_to: '',
responsible_team: '',
linked_module: '',
next_due_date: '',
due_reminder_days: 14,
notes: '',
}
const EMPTY_COMPLETE: CompleteFormData = {
completed_by: '',
result: '',
notes: '',
}
const API = '/api/sdk/v1/compliance/process-tasks'
// =============================================================================
// Constants
// =============================================================================
const CATEGORY_LABELS: Record<string, string> = {
dsgvo: 'DSGVO',
nis2: 'NIS2',
bsi: 'BSI',
iso27001: 'ISO 27001',
ai_act: 'AI Act',
internal: 'Intern',
}
const CATEGORY_COLORS: Record<string, string> = {
dsgvo: 'bg-blue-100 text-blue-700',
nis2: 'bg-purple-100 text-purple-700',
bsi: 'bg-green-100 text-green-700',
iso27001: 'bg-indigo-100 text-indigo-700',
ai_act: 'bg-orange-100 text-orange-700',
internal: 'bg-gray-100 text-gray-600',
}
const PRIORITY_LABELS: Record<string, string> = {
critical: 'Kritisch',
high: 'Hoch',
medium: 'Mittel',
low: 'Niedrig',
}
const PRIORITY_COLORS: Record<string, string> = {
critical: 'bg-red-100 text-red-700',
high: 'bg-orange-100 text-orange-700',
medium: 'bg-yellow-100 text-yellow-700',
low: 'bg-green-100 text-green-700',
}
const STATUS_LABELS: Record<string, string> = {
pending: 'Ausstehend',
in_progress: 'In Bearbeitung',
completed: 'Erledigt',
overdue: 'Ueberfaellig',
skipped: 'Uebersprungen',
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-gray-100 text-gray-600',
in_progress: 'bg-blue-100 text-blue-700',
completed: 'bg-green-100 text-green-700',
overdue: 'bg-red-100 text-red-700',
skipped: 'bg-yellow-100 text-yellow-700',
}
const STATUS_ICONS: Record<string, string> = {
pending: '\u25CB',
in_progress: '\u25D4',
completed: '\u2714',
overdue: '\u26A0',
skipped: '\u2192',
}
const FREQUENCY_LABELS: Record<string, string> = {
weekly: 'Woechentlich',
monthly: 'Monatlich',
quarterly: 'Quartalsweise',
semi_annual: 'Halbjaehrlich',
yearly: 'Jaehrlich',
once: 'Einmalig',
}
// =============================================================================
// Helpers
// =============================================================================
function formatDate(d: string | null): string {
if (!d) return '\u2014'
const dt = new Date(d)
return dt.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function daysUntil(d: string | null): number | null {
if (!d) return null
return Math.ceil((new Date(d).getTime() - Date.now()) / 86400000)
}
function dueLabel(d: string | null): string {
const days = daysUntil(d)
if (days === null) return '\u2014'
if (days < 0) return `${Math.abs(days)} Tage ueberfaellig`
if (days === 0) return 'Heute faellig'
if (days === 1) return 'Morgen faellig'
return `In ${days} Tagen`
}
function dueLabelColor(d: string | null): string {
const days = daysUntil(d)
if (days === null) return 'text-gray-400'
if (days < 0) return 'text-red-600 font-semibold'
if (days <= 7) return 'text-orange-600'
if (days <= 30) return 'text-yellow-600'
return 'text-green-600'
}
// =============================================================================
// Toast
// =============================================================================
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => {
const t = setTimeout(onClose, 3000)
return () => clearTimeout(t)
}, [onClose])
return (
<div className="fixed bottom-6 right-6 z-[60] bg-gray-900 text-white px-5 py-3 rounded-xl shadow-xl text-sm animate-slide-up">
{message}
</div>
)
}
// =============================================================================
// Complete Modal
// =============================================================================
function CompleteModal({
task,
onClose,
onComplete,
}: {
task: ProcessTask
onClose: () => void
onComplete: (data: CompleteFormData) => Promise<void>
}) {
const [form, setForm] = useState<CompleteFormData>({ ...EMPTY_COMPLETE })
const [saving, setSaving] = useState(false)
const handleSave = async () => {
setSaving(true)
try {
await onComplete(form)
onClose()
} catch {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900">Aufgabe erledigen</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">{'\u2715'}</button>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-gray-600 font-medium">{task.title}</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Erledigt von</label>
<input
type="text"
value={form.completed_by}
onChange={e => setForm(prev => ({ ...prev, completed_by: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Name / Rolle"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ergebnis</label>
<textarea
value={form.result}
onChange={e => setForm(prev => ({ ...prev, result: e.target.value }))}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Ergebnis der Pruefung / Massnahme..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
<textarea
value={form.notes}
onChange={e => setForm(prev => ({ ...prev, notes: e.target.value }))}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Zusaetzliche Hinweise..."
/>
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Als erledigt markieren'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// Skip Modal
// =============================================================================
function SkipModal({
task,
onClose,
onSkip,
}: {
task: ProcessTask
onClose: () => void
onSkip: (reason: string) => Promise<void>
}) {
const [reason, setReason] = useState('')
const [saving, setSaving] = useState(false)
const handleSkip = async () => {
if (!reason.trim()) return
setSaving(true)
try {
await onSkip(reason)
onClose()
} catch {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900">Aufgabe ueberspringen</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">{'\u2715'}</button>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-gray-600 font-medium">{task.title}</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Begruendung *</label>
<textarea
value={reason}
onChange={e => setReason(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Warum wird diese Aufgabe uebersprungen?"
/>
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button
onClick={handleSkip}
disabled={saving || !reason.trim()}
className="px-5 py-2 text-sm bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Ueberspringen'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// Task Form Modal (Create / Edit)
// =============================================================================
function TaskFormModal({
initial,
onClose,
onSave,
}: {
initial?: Partial<TaskFormData>
onClose: () => void
onSave: (data: TaskFormData) => Promise<void>
}) {
const [form, setForm] = useState<TaskFormData>({ ...EMPTY_FORM, ...initial })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const update = (field: keyof TaskFormData, value: string | number) =>
setForm(prev => ({ ...prev, [field]: value }))
const handleSave = async () => {
if (!form.title.trim()) { setError('Titel ist erforderlich'); return }
if (!form.task_code.trim()) { setError('Task-Code ist erforderlich'); return }
setSaving(true)
setError(null)
try {
await onSave(form)
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-6 border-b">
<h2 className="text-lg font-semibold text-gray-900">
{initial?.title ? 'Aufgabe bearbeiten' : 'Neue Aufgabe erstellen'}
</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">{'\u2715'}</button>
</div>
<div className="p-6 space-y-4">
{error && <div className="text-red-600 text-sm bg-red-50 rounded-lg p-3">{error}</div>}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Task-Code *</label>
<input
type="text"
value={form.task_code}
onChange={e => update('task_code', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="z.B. DSGVO-VVT-REVIEW"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={form.category}
onChange={e => update('category', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input
type="text"
value={form.title}
onChange={e => update('title', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="z.B. VVT-Review und Aktualisierung"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={form.description}
onChange={e => update('description', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Detaillierte Beschreibung..."
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet</label>
<select
value={form.priority}
onChange={e => update('priority', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{Object.entries(PRIORITY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Frequenz</label>
<select
value={form.frequency}
onChange={e => update('frequency', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
{Object.entries(FREQUENCY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Faellig am</label>
<input
type="date"
value={form.next_due_date}
onChange={e => update('next_due_date', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Zustaendig</label>
<input
type="text"
value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="z.B. DSB, CISO"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Team</label>
<input
type="text"
value={form.responsible_team}
onChange={e => update('responsible_team', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="z.B. IT-Sicherheit"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepftes Modul</label>
<input
type="text"
value={form.linked_module}
onChange={e => update('linked_module', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="z.B. vvt, tom, dsfa"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Erinnerung (Tage vorher)</label>
<input
type="number"
value={form.due_reminder_days}
onChange={e => update('due_reminder_days', parseInt(e.target.value) || 14)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
min={0}
max={90}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
<textarea
value={form.notes}
onChange={e => update('notes', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="Interne Hinweise..."
/>
</div>
</div>
<div className="flex justify-end gap-3 p-6 border-t">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// Detail Modal (with History)
// =============================================================================
function TaskDetailModal({
task,
onClose,
onComplete,
onSkip,
onEdit,
onDelete,
}: {
task: ProcessTask
onClose: () => void
onComplete: (task: ProcessTask) => void
onSkip: (task: ProcessTask) => void
onEdit: (task: ProcessTask) => void
onDelete: (id: string) => Promise<void>
}) {
const [history, setHistory] = useState<HistoryEntry[]>([])
const [loadingHistory, setLoadingHistory] = useState(true)
useEffect(() => {
loadHistory()
}, [task.id])
const loadHistory = async () => {
try {
const res = await fetch(`${API}/${task.id}/history`)
if (res.ok) {
const data = await res.json()
setHistory(data.history || [])
}
} catch { /* ignore */ }
setLoadingHistory(false)
}
const handleDelete = async () => {
if (!confirm('Aufgabe wirklich loeschen?')) return
await onDelete(task.id)
onClose()
}
const days = daysUntil(task.next_due_date)
return (
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto">
<div className="flex items-start justify-between p-6 border-b gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-0.5 text-xs rounded-full ${CATEGORY_COLORS[task.category] || 'bg-gray-100 text-gray-600'}`}>
{CATEGORY_LABELS[task.category] || task.category}
</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${PRIORITY_COLORS[task.priority]}`}>
{PRIORITY_LABELS[task.priority]}
</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[task.status]}`}>
{STATUS_LABELS[task.status]}
</span>
</div>
<h2 className="text-base font-semibold text-gray-900">{task.title}</h2>
<p className="text-xs text-gray-400 mt-0.5">{task.task_code}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 flex-shrink-0">{'\u2715'}</button>
</div>
<div className="p-6 space-y-4 text-sm">
{task.description && (
<p className="text-gray-700 whitespace-pre-wrap">{task.description}</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-gray-500">Frequenz</span>
<p className="font-medium text-gray-900">{FREQUENCY_LABELS[task.frequency] || task.frequency}</p>
</div>
<div>
<span className="text-gray-500">Faellig</span>
<p className={`font-medium ${dueLabelColor(task.next_due_date)}`}>
{task.next_due_date ? `${formatDate(task.next_due_date)} (${dueLabel(task.next_due_date)})` : '\u2014'}
</p>
</div>
<div>
<span className="text-gray-500">Zustaendig</span>
<p className="font-medium text-gray-900">{task.assigned_to || '\u2014'}</p>
</div>
<div>
<span className="text-gray-500">Team</span>
<p className="font-medium text-gray-900">{task.responsible_team || '\u2014'}</p>
</div>
{task.linked_module && (
<div>
<span className="text-gray-500">Modul</span>
<p className="font-medium text-purple-700">{task.linked_module}</p>
</div>
)}
{task.last_completed_at && (
<div>
<span className="text-gray-500">Zuletzt erledigt</span>
<p className="font-medium text-gray-900">{formatDate(task.last_completed_at)}</p>
</div>
)}
</div>
{task.notes && (
<div className="bg-gray-50 rounded-lg p-3">
<span className="text-gray-500 text-xs">Notizen</span>
<p className="text-gray-700 mt-1">{task.notes}</p>
</div>
)}
{/* History */}
<div className="border-t pt-4">
<h3 className="font-semibold text-gray-900 mb-2">Verlauf</h3>
{loadingHistory ? (
<div className="animate-pulse space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
) : history.length === 0 ? (
<p className="text-gray-400 text-sm">Noch keine Eintraege</p>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{history.map(h => (
<div key={h.id} className="flex items-start gap-2 text-xs bg-gray-50 rounded-lg p-2">
<span className={`px-1.5 py-0.5 rounded ${h.status === 'completed' ? 'bg-green-100 text-green-700' : 'bg-yellow-100 text-yellow-700'}`}>
{h.status === 'completed' ? 'Erledigt' : 'Uebersprungen'}
</span>
<div className="flex-1">
<p className="text-gray-600">{formatDate(h.completed_at)} {h.completed_by ? `von ${h.completed_by}` : ''}</p>
{h.result && <p className="text-gray-700 mt-0.5">{h.result}</p>}
{h.notes && <p className="text-gray-500 mt-0.5">{h.notes}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2 p-6 border-t flex-wrap">
{task.status !== 'completed' && (
<>
<button
onClick={() => { onClose(); onComplete(task) }}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Erledigen
</button>
<button
onClick={() => { onClose(); onSkip(task) }}
className="px-3 py-1.5 text-sm bg-yellow-500 text-white rounded-lg hover:bg-yellow-600"
>
Ueberspringen
</button>
</>
)}
<button
onClick={() => { onClose(); onEdit(task) }}
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg border"
>
Bearbeiten
</button>
<button
onClick={handleDelete}
className="px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 rounded-lg border border-red-200 ml-auto"
>
Loeschen
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// Calendar View
// =============================================================================
function CalendarView({ tasks }: { tasks: ProcessTask[] }) {
const [currentMonth, setCurrentMonth] = useState(() => {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 1)
})
const year = currentMonth.getFullYear()
const month = currentMonth.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOfWeek = new Date(year, month, 1).getDay()
const startOffset = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1 // Monday start
const monthLabel = currentMonth.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
// Group tasks by date
const tasksByDate: Record<string, ProcessTask[]> = {}
tasks.forEach(t => {
if (!t.next_due_date) return
const key = t.next_due_date.substring(0, 10)
if (!tasksByDate[key]) tasksByDate[key] = []
tasksByDate[key].push(t)
})
const prev = () => setCurrentMonth(new Date(year, month - 1, 1))
const next = () => setCurrentMonth(new Date(year, month + 1, 1))
const today = new Date().toISOString().substring(0, 10)
return (
<div>
<div className="flex items-center justify-between mb-4">
<button onClick={prev} className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">&larr; Vorher</button>
<h3 className="text-lg font-semibold text-gray-900">{monthLabel}</h3>
<button onClick={next} className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Weiter &rarr;</button>
</div>
<div className="grid grid-cols-7 gap-px bg-gray-200 rounded-xl overflow-hidden border border-gray-200">
{['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'].map(d => (
<div key={d} className="bg-gray-50 p-2 text-xs font-medium text-gray-500 text-center">{d}</div>
))}
{Array.from({ length: startOffset }).map((_, i) => (
<div key={`empty-${i}`} className="bg-white p-2 min-h-[80px]"></div>
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const dayTasks = tasksByDate[dateStr] || []
const isToday = dateStr === today
return (
<div key={day} className={`bg-white p-2 min-h-[80px] ${isToday ? 'ring-2 ring-purple-400 ring-inset' : ''}`}>
<span className={`text-xs font-medium ${isToday ? 'text-purple-600' : 'text-gray-500'}`}>{day}</span>
<div className="mt-1 space-y-0.5">
{dayTasks.slice(0, 3).map(t => {
const days = daysUntil(t.next_due_date)
let dotColor = 'bg-gray-400'
if (t.status === 'completed') dotColor = 'bg-green-500'
else if (days !== null && days < 0) dotColor = 'bg-red-500'
else if (days !== null && days <= 7) dotColor = 'bg-orange-500'
return (
<div key={t.id} className="flex items-center gap-1" title={t.title}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${dotColor}`}></span>
<span className="text-[10px] text-gray-600 truncate">{t.title}</span>
</div>
)
})}
{dayTasks.length > 3 && (
<span className="text-[10px] text-gray-400">+{dayTasks.length - 3} mehr</span>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
// =============================================================================
// Main Page
// =============================================================================
export default function ProcessTasksPage() {
const sdk = useSDK()
const [activeTab, setActiveTab] = useState<'overview' | 'all' | 'calendar'>('overview')
// Data
const [tasks, setTasks] = useState<ProcessTask[]>([])
const [totalTasks, setTotalTasks] = useState(0)
const [stats, setStats] = useState<TaskStats | null>(null)
const [upcomingTasks, setUpcomingTasks] = useState<ProcessTask[]>([])
// Filters
const [filterStatus, setFilterStatus] = useState('')
const [filterCategory, setFilterCategory] = useState('')
const [filterFrequency, setFilterFrequency] = useState('')
const [page, setPage] = useState(0)
const PAGE_SIZE = 25
// UI State
const [loading, setLoading] = useState(true)
const [toast, setToast] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [editTask, setEditTask] = useState<ProcessTask | null>(null)
const [detailTask, setDetailTask] = useState<ProcessTask | null>(null)
const [completeTask, setCompleteTask] = useState<ProcessTask | null>(null)
const [skipTask, setSkipTask] = useState<ProcessTask | null>(null)
// Load data
const loadStats = useCallback(async () => {
try {
const res = await fetch(`${API}/stats`)
if (res.ok) setStats(await res.json())
} catch { /* ignore */ }
}, [])
const loadUpcoming = useCallback(async () => {
try {
const res = await fetch(`${API}/upcoming?days=30`)
if (res.ok) {
const data = await res.json()
setUpcomingTasks(data.tasks || [])
}
} catch { /* ignore */ }
}, [])
const loadTasks = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterStatus) params.set('status', filterStatus)
if (filterCategory) params.set('category', filterCategory)
if (filterFrequency) params.set('frequency', filterFrequency)
params.set('limit', String(PAGE_SIZE))
params.set('offset', String(page * PAGE_SIZE))
const res = await fetch(`${API}?${params}`)
if (res.ok) {
const data = await res.json()
setTasks(data.tasks || [])
setTotalTasks(data.total || 0)
}
} catch { /* ignore */ }
setLoading(false)
}, [filterStatus, filterCategory, filterFrequency, page])
useEffect(() => {
loadStats()
loadUpcoming()
loadTasks()
}, [loadStats, loadUpcoming, loadTasks])
// Actions
const handleCreate = async (data: TaskFormData) => {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || 'Fehler beim Erstellen')
}
setToast('Aufgabe erstellt')
loadTasks()
loadStats()
loadUpcoming()
}
const handleUpdate = async (data: TaskFormData) => {
if (!editTask) return
const res = await fetch(`${API}/${editTask.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || 'Fehler beim Speichern')
}
setEditTask(null)
setToast('Aufgabe aktualisiert')
loadTasks()
loadStats()
loadUpcoming()
}
const handleComplete = async (data: CompleteFormData) => {
if (!completeTask) return
const res = await fetch(`${API}/${completeTask.id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error('Fehler')
setCompleteTask(null)
setToast('Aufgabe als erledigt markiert')
loadTasks()
loadStats()
loadUpcoming()
}
const handleSkip = async (reason: string) => {
if (!skipTask) return
const res = await fetch(`${API}/${skipTask.id}/skip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }),
})
if (!res.ok) throw new Error('Fehler')
setSkipTask(null)
setToast('Aufgabe uebersprungen')
loadTasks()
loadStats()
loadUpcoming()
}
const handleDelete = async (id: string) => {
const res = await fetch(`${API}/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Fehler beim Loeschen')
setToast('Aufgabe geloescht')
loadTasks()
loadStats()
loadUpcoming()
}
const handleSeed = async () => {
const res = await fetch(`${API}/seed`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
setToast(`${data.seeded} Standard-Aufgaben erstellt`)
loadTasks()
loadStats()
loadUpcoming()
}
}
const totalPages = Math.ceil(totalTasks / PAGE_SIZE)
// =============================================================================
// Render
// =============================================================================
return (
<div className="max-w-7xl mx-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Compliance Process Manager</h1>
<p className="text-sm text-gray-500 mt-1">Wiederkehrende Compliance-Aufgaben verwalten und nachverfolgen</p>
</div>
<div className="flex gap-2">
<button
onClick={handleSeed}
className="px-4 py-2 text-sm text-purple-600 border border-purple-200 rounded-lg hover:bg-purple-50"
>
Standard-Aufgaben laden
</button>
<button
onClick={() => setShowForm(true)}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
+ Neue Aufgabe
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 bg-gray-100 rounded-xl p-1 mb-6 w-fit">
{[
{ key: 'overview' as const, label: 'Uebersicht' },
{ key: 'all' as const, label: 'Alle Aufgaben' },
{ key: 'calendar' as const, label: 'Kalender' },
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm rounded-lg transition-colors ${
activeTab === tab.key
? 'bg-white text-gray-900 shadow-sm font-medium'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
{/* ===== OVERVIEW TAB ===== */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Stat Cards */}
{stats ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-red-200 p-5">
<div className="text-sm text-red-600 font-medium">Ueberfaellig</div>
<div className="text-3xl font-bold text-red-700 mt-1">{stats.overdue_count}</div>
<div className="text-xs text-gray-500 mt-1">Sofortiger Handlungsbedarf</div>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-5">
<div className="text-sm text-orange-600 font-medium">Diese Woche</div>
<div className="text-3xl font-bold text-orange-700 mt-1">{stats.due_7_days}</div>
<div className="text-xs text-gray-500 mt-1">Naechste 7 Tage</div>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-5">
<div className="text-sm text-yellow-600 font-medium">Diesen Monat</div>
<div className="text-3xl font-bold text-yellow-700 mt-1">{stats.due_30_days}</div>
<div className="text-xs text-gray-500 mt-1">Naechste 30 Tage</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-5">
<div className="text-sm text-green-600 font-medium">Erledigt</div>
<div className="text-3xl font-bold text-green-700 mt-1">{stats.by_status.completed || 0}</div>
<div className="text-xs text-gray-500 mt-1">von {stats.total} Aufgaben</div>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="bg-white rounded-xl border p-5 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-24 mb-2"></div>
<div className="h-8 bg-gray-200 rounded w-12 mb-1"></div>
<div className="h-3 bg-gray-200 rounded w-32"></div>
</div>
))}
</div>
)}
{/* Category Breakdown */}
{stats && Object.keys(stats.by_category).length > 0 && (
<div className="bg-white rounded-xl border p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Nach Kategorie</h3>
<div className="flex flex-wrap gap-2">
{Object.entries(stats.by_category).map(([cat, count]) => (
<span key={cat} className={`px-3 py-1.5 text-sm rounded-lg ${CATEGORY_COLORS[cat] || 'bg-gray-100 text-gray-600'}`}>
{CATEGORY_LABELS[cat] || cat}: {count}
</span>
))}
</div>
</div>
)}
{/* Upcoming Tasks */}
<div className="bg-white rounded-xl border">
<div className="p-5 border-b">
<h3 className="text-sm font-semibold text-gray-900">Naechste faellige Aufgaben</h3>
</div>
{upcomingTasks.length === 0 ? (
<div className="p-8 text-center text-gray-400">
<p className="text-sm">Keine Aufgaben in den naechsten 30 Tagen faellig.</p>
{stats && stats.total === 0 && (
<button
onClick={handleSeed}
className="mt-3 px-4 py-2 text-sm text-purple-600 border border-purple-200 rounded-lg hover:bg-purple-50"
>
Standard-Aufgaben laden
</button>
)}
</div>
) : (
<div className="divide-y">
{upcomingTasks.slice(0, 5).map(t => (
<div
key={t.id}
className="flex items-center gap-4 p-4 hover:bg-gray-50 cursor-pointer"
onClick={() => setDetailTask(t)}
>
<span className={`w-8 h-8 flex items-center justify-center rounded-full text-sm ${STATUS_COLORS[t.status]}`}>
{STATUS_ICONS[t.status]}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{t.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-xs rounded px-1.5 py-0.5 ${CATEGORY_COLORS[t.category]}`}>
{CATEGORY_LABELS[t.category]}
</span>
<span className="text-xs text-gray-400">{FREQUENCY_LABELS[t.frequency]}</span>
</div>
</div>
<div className="text-right flex-shrink-0">
<p className={`text-sm ${dueLabelColor(t.next_due_date)}`}>{dueLabel(t.next_due_date)}</p>
<p className="text-xs text-gray-400">{formatDate(t.next_due_date)}</p>
</div>
<button
onClick={e => { e.stopPropagation(); setCompleteTask(t) }}
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 flex-shrink-0"
>
Erledigen
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* ===== ALL TASKS TAB ===== */}
{activeTab === 'all' && (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
value={filterStatus}
onChange={e => { setFilterStatus(e.target.value); setPage(0) }}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white"
>
<option value="">Alle Status</option>
{Object.entries(STATUS_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<select
value={filterCategory}
onChange={e => { setFilterCategory(e.target.value); setPage(0) }}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white"
>
<option value="">Alle Kategorien</option>
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<select
value={filterFrequency}
onChange={e => { setFilterFrequency(e.target.value); setPage(0) }}
className="px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white"
>
<option value="">Alle Frequenzen</option>
{Object.entries(FREQUENCY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<span className="text-sm text-gray-400 ml-auto">{totalTasks} Aufgaben</span>
</div>
{/* Table */}
<div className="bg-white rounded-xl border overflow-hidden">
{loading ? (
<div className="p-6 space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="flex gap-4 animate-pulse">
<div className="h-8 w-8 bg-gray-200 rounded-full"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
<div className="h-3 bg-gray-200 rounded w-1/4"></div>
</div>
</div>
))}
</div>
) : tasks.length === 0 ? (
<div className="p-12 text-center text-gray-400">
<p className="text-lg mb-1">Keine Aufgaben gefunden</p>
<p className="text-sm">Erstellen Sie eine neue Aufgabe oder laden Sie die Standard-Aufgaben.</p>
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-gray-50">
<th className="text-left py-3 px-4 font-medium text-gray-500 w-10">Status</th>
<th className="text-left py-3 px-4 font-medium text-gray-500">Titel</th>
<th className="text-left py-3 px-4 font-medium text-gray-500">Kategorie</th>
<th className="text-left py-3 px-4 font-medium text-gray-500">Frequenz</th>
<th className="text-left py-3 px-4 font-medium text-gray-500">Faellig am</th>
<th className="text-left py-3 px-4 font-medium text-gray-500">Zustaendig</th>
<th className="text-right py-3 px-4 font-medium text-gray-500">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y">
{tasks.map(t => (
<tr
key={t.id}
className="hover:bg-gray-50 cursor-pointer"
onClick={() => setDetailTask(t)}
>
<td className="py-3 px-4">
<span className={`w-7 h-7 flex items-center justify-center rounded-full text-xs ${STATUS_COLORS[t.status]}`}>
{STATUS_ICONS[t.status]}
</span>
</td>
<td className="py-3 px-4">
<p className="font-medium text-gray-900">{t.title}</p>
<p className="text-xs text-gray-400">{t.task_code}</p>
</td>
<td className="py-3 px-4">
<span className={`px-2 py-0.5 text-xs rounded-full ${CATEGORY_COLORS[t.category]}`}>
{CATEGORY_LABELS[t.category] || t.category}
</span>
</td>
<td className="py-3 px-4 text-gray-600">
{FREQUENCY_LABELS[t.frequency] || t.frequency}
</td>
<td className="py-3 px-4">
<span className={dueLabelColor(t.next_due_date)}>{formatDate(t.next_due_date)}</span>
<p className={`text-xs ${dueLabelColor(t.next_due_date)}`}>{dueLabel(t.next_due_date)}</p>
</td>
<td className="py-3 px-4 text-gray-600">{t.assigned_to || '\u2014'}</td>
<td className="py-3 px-4 text-right" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
{t.status !== 'completed' && (
<button
onClick={() => setCompleteTask(t)}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
title="Erledigen"
>
{'\u2714'}
</button>
)}
{t.status !== 'completed' && (
<button
onClick={() => setSkipTask(t)}
className="px-2 py-1 text-xs bg-yellow-500 text-white rounded hover:bg-yellow-600"
title="Ueberspringen"
>
{'\u2192'}
</button>
)}
<button
onClick={() => {
setEditTask(t)
}}
className="px-2 py-1 text-xs text-gray-500 hover:bg-gray-100 rounded border"
title="Bearbeiten"
>
{'\u270E'}
</button>
<button
onClick={() => {
if (confirm('Aufgabe wirklich loeschen?')) handleDelete(t.id)
}}
className="px-2 py-1 text-xs text-red-500 hover:bg-red-50 rounded border border-red-200"
title="Loeschen"
>
{'\u2716'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg border disabled:opacity-40"
>
Zurueck
</button>
<span className="text-sm text-gray-500">
Seite {page + 1} von {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg border disabled:opacity-40"
>
Weiter
</button>
</div>
)}
</div>
)}
{/* ===== CALENDAR TAB ===== */}
{activeTab === 'calendar' && (
<div className="bg-white rounded-xl border p-5">
<CalendarView tasks={[...tasks, ...upcomingTasks].filter((t, i, arr) => arr.findIndex(x => x.id === t.id) === i)} />
</div>
)}
{/* ===== MODALS ===== */}
{showForm && (
<TaskFormModal
onClose={() => setShowForm(false)}
onSave={handleCreate}
/>
)}
{editTask && (
<TaskFormModal
initial={{
task_code: editTask.task_code,
title: editTask.title,
description: editTask.description || '',
category: editTask.category,
priority: editTask.priority,
frequency: editTask.frequency,
assigned_to: editTask.assigned_to || '',
responsible_team: editTask.responsible_team || '',
linked_module: editTask.linked_module || '',
next_due_date: editTask.next_due_date ? editTask.next_due_date.substring(0, 10) : '',
due_reminder_days: editTask.due_reminder_days,
notes: editTask.notes || '',
}}
onClose={() => setEditTask(null)}
onSave={handleUpdate}
/>
)}
{detailTask && (
<TaskDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
onComplete={t => { setDetailTask(null); setCompleteTask(t) }}
onSkip={t => { setDetailTask(null); setSkipTask(t) }}
onEdit={t => { setDetailTask(null); setEditTask(t) }}
onDelete={handleDelete}
/>
)}
{completeTask && (
<CompleteModal
task={completeTask}
onClose={() => setCompleteTask(null)}
onComplete={handleComplete}
/>
)}
{skipTask && (
<SkipModal
task={skipTask}
onClose={() => setSkipTask(null)}
onSkip={handleSkip}
/>
)}
{toast && <Toast message={toast} onClose={() => setToast(null)} />}
</div>
)
}