Each page.tsx was >1000 LOC; extract components to _components/ and hooks to _hooks/ so page files stay under 500 LOC (164 / 255 / 243 respectively). Zero behavior changes — logic relocated verbatim. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import React, { useMemo } from 'react'
|
|
import GapAnalysisView from '@/components/sdk/obligations/GapAnalysisView'
|
|
import { ObligationDocumentTab } from '@/components/sdk/obligations/ObligationDocumentTab'
|
|
import { useObligations } from './_hooks/useObligations'
|
|
import ObligationModal from './_components/ObligationModal'
|
|
import ObligationDetail from './_components/ObligationDetail'
|
|
import ObligationCard from './_components/ObligationCard'
|
|
import StatsGrid from './_components/StatsGrid'
|
|
import FilterBar from './_components/FilterBar'
|
|
import { ApplicableRegsBanner, NoProfileWarning, OverdueAlert, EmptyList } from './_components/InfoBanners'
|
|
import ObligationsHeader from './_components/ObligationsHeader'
|
|
|
|
// Tab definitions
|
|
type Tab = 'uebersicht' | 'editor' | 'profiling' | 'gap-analyse' | 'pflichtenregister'
|
|
const TABS: { key: Tab; label: string }[] = [
|
|
{ key: 'uebersicht', label: 'Uebersicht' },
|
|
{ key: 'editor', label: 'Detail-Editor' },
|
|
{ key: 'profiling', label: 'Profiling' },
|
|
{ key: 'gap-analyse', label: 'Gap-Analyse' },
|
|
{ key: 'pflichtenregister', label: 'Pflichtenregister' },
|
|
]
|
|
|
|
export default function ObligationsPage() {
|
|
const o = useObligations()
|
|
|
|
const complianceScore = o.complianceResult ? o.complianceResult.score : null
|
|
|
|
const renderUebersichtTab = () => (
|
|
<>
|
|
{o.error && (
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{o.error}</div>
|
|
)}
|
|
<ApplicableRegsBanner regs={o.applicableRegs} />
|
|
{!o.sdkState.companyProfile && <NoProfileWarning />}
|
|
<StatsGrid stats={o.stats} complianceScore={complianceScore} />
|
|
<OverdueAlert stats={o.stats} />
|
|
|
|
{o.complianceResult && o.complianceResult.issues.length > 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">Compliance-Befunde ({o.complianceResult.issues.length})</h3>
|
|
<div className="space-y-2">
|
|
{o.complianceResult.issues.map((issue, i) => (
|
|
<div key={i} className="flex items-start gap-3 text-sm">
|
|
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
|
|
issue.severity === 'CRITICAL' ? 'bg-red-100 text-red-700' :
|
|
issue.severity === 'HIGH' ? 'bg-orange-100 text-orange-700' :
|
|
issue.severity === 'MEDIUM' ? 'bg-yellow-100 text-yellow-700' :
|
|
'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{issue.severity === 'CRITICAL' ? 'Kritisch' : issue.severity === 'HIGH' ? 'Hoch' : issue.severity === 'MEDIUM' ? 'Mittel' : 'Niedrig'}
|
|
</span>
|
|
<span className="text-gray-700">{issue.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<FilterBar
|
|
filter={o.filter}
|
|
regulationFilter={o.regulationFilter}
|
|
searchQuery={o.searchQuery}
|
|
onFilter={o.setFilter}
|
|
onRegulationFilter={o.setRegulationFilter}
|
|
onSearch={o.setSearchQuery}
|
|
/>
|
|
|
|
{o.loading && <div className="text-center py-8 text-gray-500 text-sm">Lade Pflichten...</div>}
|
|
|
|
{!o.loading && (
|
|
<div className="space-y-3">
|
|
{o.filteredObligations.map(obl => (
|
|
<ObligationCard
|
|
key={obl.id}
|
|
obligation={obl}
|
|
onStatusChange={o.handleStatusChange}
|
|
onDetails={() => o.setDetailObligation(obl)}
|
|
/>
|
|
))}
|
|
{o.filteredObligations.length === 0 && <EmptyList onCreate={() => o.setShowModal(true)} />}
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
const renderEditorTab = () => (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-sm font-semibold text-gray-900">Pflichten bearbeiten ({o.obligations.length})</h3>
|
|
<button onClick={() => o.setShowModal(true)} className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm">
|
|
Pflicht hinzufuegen
|
|
</button>
|
|
</div>
|
|
{o.loading && <p className="text-gray-500 text-sm">Lade...</p>}
|
|
{!o.loading && o.obligations.length === 0 && (
|
|
<p className="text-gray-500 text-sm">Noch keine Pflichten vorhanden. Erstellen Sie eine neue Pflicht oder nutzen Sie Auto-Profiling.</p>
|
|
)}
|
|
{!o.loading && o.obligations.length > 0 && (
|
|
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
|
|
{o.obligations.map(obl => (
|
|
<div
|
|
key={obl.id}
|
|
className="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:bg-gray-50 cursor-pointer"
|
|
onClick={() => o.setEditObligation(obl)}
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<span className="text-sm text-gray-900 truncate">{obl.title}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<span className="text-xs text-gray-400">{obl.source}</span>
|
|
<button onClick={(e) => { e.stopPropagation(); o.setEditObligation(obl) }} className="text-xs text-purple-600 hover:text-purple-700 font-medium">
|
|
Bearbeiten
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
const renderProfilingTab = () => (
|
|
<>
|
|
{o.error && <div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{o.error}</div>}
|
|
{!o.sdkState.companyProfile && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-700">
|
|
Kein Unternehmensprofil vorhanden. Auto-Profiling verwendet Beispieldaten.{' '}
|
|
<a href="/sdk/company-profile" className="underline font-medium">Profil anlegen →</a>
|
|
</div>
|
|
)}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
|
|
<div className="w-12 h-12 mx-auto bg-purple-50 rounded-full flex items-center justify-center mb-3">
|
|
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-gray-900">Auto-Profiling</h3>
|
|
<p className="text-xs text-gray-500 mt-1 mb-4">
|
|
Ermittelt automatisch anwendbare Regulierungen und Pflichten aus dem Unternehmensprofil und Compliance-Scope.
|
|
</p>
|
|
<button
|
|
onClick={o.handleAutoProfiling}
|
|
disabled={o.profiling}
|
|
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
{o.profiling ? 'Profiling laeuft...' : 'Auto-Profiling starten'}
|
|
</button>
|
|
</div>
|
|
{o.applicableRegs.length > 0 && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
<h3 className="text-sm font-semibold text-blue-900 mb-2">Anwendbare Regulierungen</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{o.applicableRegs.map(reg => (
|
|
<span key={reg.id} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-white border border-blue-300 text-blue-800">
|
|
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
{reg.name}
|
|
{reg.classification && <span className="text-blue-500">({reg.classification})</span>}
|
|
<span className="text-blue-400">{reg.obligation_count} Pflichten</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
const renderTabContent = () => {
|
|
switch (o.activeTab) {
|
|
case 'uebersicht': return renderUebersichtTab()
|
|
case 'editor': return renderEditorTab()
|
|
case 'profiling': return renderProfilingTab()
|
|
case 'gap-analyse': return <GapAnalysisView />
|
|
case 'pflichtenregister': return <ObligationDocumentTab obligations={o.obligations} complianceResult={o.complianceResult} />
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Modals */}
|
|
{(o.showModal || o.editObligation) && !o.detailObligation && (
|
|
<ObligationModal
|
|
initial={o.editObligation ? {
|
|
title: o.editObligation.title,
|
|
description: o.editObligation.description,
|
|
source: o.editObligation.source,
|
|
source_article: o.editObligation.source_article,
|
|
deadline: o.editObligation.deadline ? o.editObligation.deadline.slice(0, 10) : '',
|
|
status: o.editObligation.status,
|
|
priority: o.editObligation.priority,
|
|
responsible: o.editObligation.responsible,
|
|
linked_systems: o.editObligation.linked_systems?.join(', ') || '',
|
|
linked_vendor_ids: o.editObligation.linked_vendor_ids?.join(', ') || '',
|
|
notes: o.editObligation.notes || '',
|
|
} : undefined}
|
|
onClose={() => { o.setShowModal(false); o.setEditObligation(null) }}
|
|
onSave={async (form) => {
|
|
if (o.editObligation) {
|
|
await o.handleUpdate(o.editObligation.id, form)
|
|
o.setEditObligation(null)
|
|
} else {
|
|
await o.handleCreate(form)
|
|
o.setShowModal(false)
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{o.detailObligation && (
|
|
<ObligationDetail
|
|
obligation={o.detailObligation}
|
|
onClose={() => o.setDetailObligation(null)}
|
|
onStatusChange={o.handleStatusChange}
|
|
onDelete={o.handleDelete}
|
|
onEdit={() => {
|
|
o.setEditObligation(o.detailObligation)
|
|
o.setDetailObligation(null)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<ObligationsHeader
|
|
profiling={o.profiling}
|
|
showGapAnalysis={o.activeTab === 'gap-analyse'}
|
|
onAutoProfiling={o.handleAutoProfiling}
|
|
onToggleGap={() => o.setActiveTab(o.activeTab === 'gap-analyse' ? 'uebersicht' : 'gap-analyse')}
|
|
onAdd={() => o.setShowModal(true)}
|
|
/>
|
|
|
|
{/* Tab Navigation */}
|
|
<div className="flex gap-1 border-b border-gray-200">
|
|
{TABS.map(tab => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => o.setActiveTab(tab.key)}
|
|
className={`px-4 py-2.5 text-sm font-medium transition-colors ${
|
|
o.activeTab === tab.key
|
|
? 'border-b-2 border-purple-500 text-purple-700'
|
|
: 'text-gray-500 hover:text-gray-700 hover:border-b-2 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{renderTabContent()}
|
|
</div>
|
|
)
|
|
}
|