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:
@@ -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">← 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 →</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
16
admin-compliance/app/sdk/process-tasks/_components/Toast.tsx
Normal file
16
admin-compliance/app/sdk/process-tasks/_components/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
179
admin-compliance/app/sdk/process-tasks/_components/types.ts
Normal file
179
admin-compliance/app/sdk/process-tasks/_components/types.ts
Normal 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'
|
||||
}
|
||||
149
admin-compliance/app/sdk/process-tasks/_hooks/useProcessTasks.ts
Normal file
149
admin-compliance/app/sdk/process-tasks/_hooks/useProcessTasks.ts
Normal 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
Reference in New Issue
Block a user