refactor(admin): split evidence, process-tasks, iace/hazards pages

Extract components and hooks into _components/ and _hooks/ subdirectories
to reduce each page.tsx to under 500 LOC (was 1545/1383/1316).

Final line counts: evidence=213, process-tasks=304, hazards=157.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-16 17:12:15 +02:00
parent e0c1d21879
commit 1fcd8244b1
27 changed files with 2621 additions and 4083 deletions

View File

@@ -0,0 +1,81 @@
'use client'
import { useState } from 'react'
import { ProcessTask, daysUntil } from './types'
export 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
const monthLabel = currentMonth.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
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>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { useState } from 'react'
import type { ProcessTask, CompleteFormData } from './types'
import { EMPTY_COMPLETE } from './types'
export 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>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { useState } from 'react'
import type { ProcessTask } from './types'
export 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>
)
}

View File

@@ -0,0 +1,165 @@
'use client'
import { useState, useEffect } from 'react'
import {
ProcessTask, HistoryEntry, API,
CATEGORY_LABELS, CATEGORY_COLORS, PRIORITY_LABELS, PRIORITY_COLORS,
STATUS_LABELS, STATUS_COLORS, FREQUENCY_LABELS,
formatDate, dueLabel, dueLabelColor,
} from './types'
export 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]) // eslint-disable-line react-hooks/exhaustive-deps
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()
}
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>
)}
<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>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useState } from 'react'
import { EMPTY_FORM, CATEGORY_LABELS, PRIORITY_LABELS, FREQUENCY_LABELS } from './types'
import type { TaskFormData } from './types'
export 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>
)
}

View File

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

View File

@@ -0,0 +1,179 @@
export 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
}
export 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
}
export 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
}
export interface CompleteFormData {
completed_by: string
result: string
notes: string
}
export 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
}
export 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: '',
}
export const EMPTY_COMPLETE: CompleteFormData = {
completed_by: '',
result: '',
notes: '',
}
export const API = '/api/sdk/v1/compliance/process-tasks'
export const CATEGORY_LABELS: Record<string, string> = {
dsgvo: 'DSGVO', nis2: 'NIS2', bsi: 'BSI',
iso27001: 'ISO 27001', ai_act: 'AI Act', internal: 'Intern',
}
export 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',
}
export const PRIORITY_LABELS: Record<string, string> = {
critical: 'Kritisch', high: 'Hoch', medium: 'Mittel', low: 'Niedrig',
}
export 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',
}
export const STATUS_LABELS: Record<string, string> = {
pending: 'Ausstehend',
in_progress: 'In Bearbeitung',
completed: 'Erledigt',
overdue: 'Ueberfaellig',
skipped: 'Uebersprungen',
}
export 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',
}
export const STATUS_ICONS: Record<string, string> = {
pending: '\u25CB',
in_progress: '\u25D4',
completed: '\u2714',
overdue: '\u26A0',
skipped: '\u2192',
}
export const FREQUENCY_LABELS: Record<string, string> = {
weekly: 'Woechentlich',
monthly: 'Monatlich',
quarterly: 'Quartalsweise',
semi_annual: 'Halbjaehrlich',
yearly: 'Jaehrlich',
once: 'Einmalig',
}
export function formatDate(d: string | null): string {
if (!d) return '\u2014'
return new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
export function daysUntil(d: string | null): number | null {
if (!d) return null
return Math.ceil((new Date(d).getTime() - Date.now()) / 86400000)
}
export 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`
}
export 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'
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { ProcessTask, TaskStats, TaskFormData, CompleteFormData, API } from '../_components/types'
export function useProcessTasks() {
const [activeTab, setActiveTab] = useState<'overview' | 'all' | 'calendar'>('overview')
const [tasks, setTasks] = useState<ProcessTask[]>([])
const [totalTasks, setTotalTasks] = useState(0)
const [stats, setStats] = useState<TaskStats | null>(null)
const [upcomingTasks, setUpcomingTasks] = useState<ProcessTask[]>([])
const [filterStatus, setFilterStatus] = useState('')
const [filterCategory, setFilterCategory] = useState('')
const [filterFrequency, setFilterFrequency] = useState('')
const [page, setPage] = useState(0)
const PAGE_SIZE = 25
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)
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])
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)
return {
activeTab, setActiveTab,
tasks, totalTasks, stats, upcomingTasks,
filterStatus, setFilterStatus,
filterCategory, setFilterCategory,
filterFrequency, setFilterFrequency,
page, setPage, PAGE_SIZE, totalPages,
loading, toast, setToast,
showForm, setShowForm,
editTask, setEditTask,
detailTask, setDetailTask,
completeTask, setCompleteTask,
skipTask, setSkipTask,
handleCreate, handleUpdate, handleComplete, handleSkip, handleDelete, handleSeed,
}
}

File diff suppressed because it is too large Load Diff