Extract ObligationModal, ObligationDetail, ObligationCard, ObligationsHeader, StatsGrid, FilterBar and InfoBanners into _components/, plus _types.ts for shared types/constants. page.tsx drops from 987 to 325 LOC, below the 300 soft target region and well under the 500 hard cap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import GapAnalysisView from '@/components/sdk/obligations/GapAnalysisView'
|
|
import { useSDK } from '@/lib/sdk'
|
|
import { buildAssessmentPayload } from '@/lib/sdk/scope-to-facts'
|
|
import type { ApplicableRegulation } from '@/lib/sdk/compliance-scope-types'
|
|
import {
|
|
API,
|
|
UCCA_API,
|
|
mapPriority,
|
|
type Obligation,
|
|
type ObligationStats,
|
|
type ObligationFormData,
|
|
} from './_types'
|
|
import ObligationModal from './_components/ObligationModal'
|
|
import ObligationDetail from './_components/ObligationDetail'
|
|
import ObligationCard from './_components/ObligationCard'
|
|
import ObligationsHeader from './_components/ObligationsHeader'
|
|
import StatsGrid from './_components/StatsGrid'
|
|
import FilterBar from './_components/FilterBar'
|
|
import {
|
|
ApplicableRegsBanner,
|
|
NoProfileWarning,
|
|
OverdueAlert,
|
|
EmptyList,
|
|
} from './_components/InfoBanners'
|
|
|
|
export default function ObligationsPage() {
|
|
const { state: sdkState } = useSDK()
|
|
const [obligations, setObligations] = useState<Obligation[]>([])
|
|
const [stats, setStats] = useState<ObligationStats | null>(null)
|
|
const [filter, setFilter] = useState('all')
|
|
const [regulationFilter, setRegulationFilter] = useState('all')
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [editObligation, setEditObligation] = useState<Obligation | null>(null)
|
|
const [detailObligation, setDetailObligation] = useState<Obligation | null>(null)
|
|
const [showGapAnalysis, setShowGapAnalysis] = useState(false)
|
|
const [profiling, setProfiling] = useState(false)
|
|
const [applicableRegs, setApplicableRegs] = useState<ApplicableRegulation[]>([])
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const params = new URLSearchParams({ limit: '200' })
|
|
if (filter !== 'all' && ['pending', 'in-progress', 'completed', 'overdue'].includes(filter)) {
|
|
params.set('status', filter)
|
|
}
|
|
if (filter === 'critical' || filter === 'high') {
|
|
params.set('priority', filter)
|
|
}
|
|
if (searchQuery) params.set('search', searchQuery)
|
|
|
|
const [listRes, statsRes] = await Promise.all([
|
|
fetch(`${API}?${params}`),
|
|
fetch(`${API}/stats`),
|
|
])
|
|
|
|
if (listRes.ok) {
|
|
const data = await listRes.json()
|
|
setObligations(data.obligations || [])
|
|
}
|
|
if (statsRes.ok) {
|
|
setStats(await statsRes.json())
|
|
}
|
|
} catch {
|
|
setError('Verbindung zum Backend fehlgeschlagen')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [filter, searchQuery])
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [loadData])
|
|
|
|
const handleCreate = async (form: ObligationFormData) => {
|
|
const res = await fetch(API, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: form.title,
|
|
description: form.description || null,
|
|
source: form.source,
|
|
source_article: form.source_article || null,
|
|
deadline: form.deadline || null,
|
|
status: form.status,
|
|
priority: form.priority,
|
|
responsible: form.responsible || null,
|
|
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
|
|
notes: form.notes || null,
|
|
}),
|
|
})
|
|
if (!res.ok) throw new Error('Erstellen fehlgeschlagen')
|
|
await loadData()
|
|
}
|
|
|
|
const handleUpdate = async (id: string, form: ObligationFormData) => {
|
|
const res = await fetch(`${API}/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: form.title,
|
|
description: form.description || null,
|
|
source: form.source,
|
|
source_article: form.source_article || null,
|
|
deadline: form.deadline || null,
|
|
status: form.status,
|
|
priority: form.priority,
|
|
responsible: form.responsible || null,
|
|
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
|
|
notes: form.notes || null,
|
|
}),
|
|
})
|
|
if (!res.ok) throw new Error('Aktualisierung fehlgeschlagen')
|
|
await loadData()
|
|
// Refresh detail if open
|
|
if (detailObligation?.id === id) {
|
|
const updated = await fetch(`${API}/${id}`)
|
|
if (updated.ok) setDetailObligation(await updated.json())
|
|
}
|
|
}
|
|
|
|
const handleStatusChange = async (id: string, newStatus: string) => {
|
|
const res = await fetch(`${API}/${id}/status`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: newStatus }),
|
|
})
|
|
if (!res.ok) return
|
|
const updated = await res.json()
|
|
setObligations(prev => prev.map(o => o.id === id ? updated : o))
|
|
if (detailObligation?.id === id) setDetailObligation(updated)
|
|
// Refresh stats
|
|
fetch(`${API}/stats`).then(r => r.json()).then(setStats).catch(() => {})
|
|
}
|
|
|
|
const handleDelete = async (id: string) => {
|
|
const res = await fetch(`${API}/${id}`, { method: 'DELETE' })
|
|
if (!res.ok && res.status !== 204) throw new Error('Loeschen fehlgeschlagen')
|
|
setObligations(prev => prev.filter(o => o.id !== id))
|
|
setDetailObligation(null)
|
|
fetch(`${API}/stats`).then(r => r.json()).then(setStats).catch(() => {})
|
|
}
|
|
|
|
const handleAutoProfiling = async () => {
|
|
setProfiling(true)
|
|
setError(null)
|
|
try {
|
|
// Build payload from real CompanyProfile + Scope data
|
|
const profile = sdkState.companyProfile
|
|
const scopeState = sdkState.complianceScope
|
|
const scopeAnswers = scopeState?.answers || []
|
|
const scopeDecision = scopeState?.decision || null
|
|
|
|
let payload: Record<string, unknown>
|
|
if (profile) {
|
|
payload = buildAssessmentPayload(profile, scopeAnswers, scopeDecision) as unknown as Record<string, unknown>
|
|
} else {
|
|
// Fallback: Minimaldaten wenn kein Profil vorhanden
|
|
payload = {
|
|
employee_count: 50,
|
|
industry: 'technology',
|
|
country: 'DE',
|
|
processes_personal_data: true,
|
|
is_controller: true,
|
|
uses_processors: true,
|
|
determined_level: scopeDecision?.determinedLevel || 'L2',
|
|
}
|
|
}
|
|
|
|
const res = await fetch(`${UCCA_API}/assess-from-scope`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
const data = await res.json()
|
|
|
|
// Store applicable regulations for the info box
|
|
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
|
setApplicableRegs(regs)
|
|
|
|
// Extract obligations from response (can be nested under overview)
|
|
const rawObls = data.overview?.obligations || data.obligations || []
|
|
if (rawObls.length > 0) {
|
|
const autoObls: Obligation[] = rawObls.map((o: Record<string, unknown>) => ({
|
|
id: o.id as string,
|
|
title: o.title as string,
|
|
description: (o.description as string) || '',
|
|
source: (o.regulation_id as string || '').toUpperCase(),
|
|
source_article: '',
|
|
deadline: null,
|
|
status: 'pending' as const,
|
|
priority: mapPriority(o.priority as string),
|
|
responsible: (o.responsible as string) || '',
|
|
linked_systems: [],
|
|
rule_code: 'auto-profiled',
|
|
}))
|
|
setObligations(prev => {
|
|
const existingIds = new Set(prev.map(p => p.id))
|
|
const newOnes = autoObls.filter(a => !existingIds.has(a.id))
|
|
return [...prev, ...newOnes]
|
|
})
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Auto-Profiling fehlgeschlagen')
|
|
} finally {
|
|
setProfiling(false)
|
|
}
|
|
}
|
|
|
|
const filteredObligations = obligations.filter(o => {
|
|
// Status/priority filter
|
|
if (filter === 'ai') {
|
|
if (!o.source.toLowerCase().includes('ai')) return false
|
|
}
|
|
// Regulation filter
|
|
if (regulationFilter !== 'all') {
|
|
const src = o.source?.toLowerCase() || ''
|
|
const key = regulationFilter.toLowerCase()
|
|
if (!src.includes(key)) return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Modals */}
|
|
{(showModal || editObligation) && !detailObligation && (
|
|
<ObligationModal
|
|
initial={editObligation ? {
|
|
title: editObligation.title,
|
|
description: editObligation.description,
|
|
source: editObligation.source,
|
|
source_article: editObligation.source_article,
|
|
deadline: editObligation.deadline ? editObligation.deadline.slice(0, 10) : '',
|
|
status: editObligation.status,
|
|
priority: editObligation.priority,
|
|
responsible: editObligation.responsible,
|
|
linked_systems: editObligation.linked_systems?.join(', ') || '',
|
|
notes: editObligation.notes || '',
|
|
} : undefined}
|
|
onClose={() => { setShowModal(false); setEditObligation(null) }}
|
|
onSave={async (form) => {
|
|
if (editObligation) {
|
|
await handleUpdate(editObligation.id, form)
|
|
setEditObligation(null)
|
|
} else {
|
|
await handleCreate(form)
|
|
setShowModal(false)
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{detailObligation && (
|
|
<ObligationDetail
|
|
obligation={detailObligation}
|
|
onClose={() => setDetailObligation(null)}
|
|
onStatusChange={handleStatusChange}
|
|
onDelete={handleDelete}
|
|
onEdit={() => {
|
|
setEditObligation(detailObligation)
|
|
setDetailObligation(null)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<ObligationsHeader
|
|
profiling={profiling}
|
|
showGapAnalysis={showGapAnalysis}
|
|
onAutoProfiling={handleAutoProfiling}
|
|
onToggleGap={() => setShowGapAnalysis(v => !v)}
|
|
onAdd={() => setShowModal(true)}
|
|
/>
|
|
|
|
{error && (
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
|
|
)}
|
|
|
|
<ApplicableRegsBanner regs={applicableRegs} />
|
|
|
|
{!sdkState.companyProfile && <NoProfileWarning />}
|
|
|
|
<StatsGrid stats={stats} />
|
|
|
|
<OverdueAlert stats={stats} />
|
|
|
|
{showGapAnalysis && <GapAnalysisView />}
|
|
|
|
<FilterBar
|
|
filter={filter}
|
|
regulationFilter={regulationFilter}
|
|
searchQuery={searchQuery}
|
|
onFilter={setFilter}
|
|
onRegulationFilter={setRegulationFilter}
|
|
onSearch={setSearchQuery}
|
|
/>
|
|
|
|
{loading && <div className="text-center py-8 text-gray-500 text-sm">Lade Pflichten...</div>}
|
|
|
|
{!loading && (
|
|
<div className="space-y-3">
|
|
{filteredObligations.map(o => (
|
|
<ObligationCard
|
|
key={o.id}
|
|
obligation={o}
|
|
onStatusChange={handleStatusChange}
|
|
onDetails={() => setDetailObligation(o)}
|
|
/>
|
|
))}
|
|
|
|
{filteredObligations.length === 0 && (
|
|
<EmptyList onCreate={() => setShowModal(true)} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|