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
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>
1384 lines
52 KiB
TypeScript
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">← 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>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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>
|
|
)
|
|
}
|