Break 839-line page.tsx into _types.ts, _components/SourcesTab.tsx, JobsTab.tsx, DocumentsTab.tsx, ReportTab.tsx, and ComplianceRing.tsx. page.tsx is now 56 LOC (wiring only). No behavior changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
190 lines
8.0 KiB
TypeScript
190 lines
8.0 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { OnboardingReport, api, CLASSIFICATION_LABELS } from '../_types'
|
|
import { ComplianceRing } from './ComplianceRing'
|
|
|
|
export function ReportTab() {
|
|
const [reports, setReports] = useState<OnboardingReport[]>([])
|
|
const [activeReport, setActiveReport] = useState<OnboardingReport | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [generating, setGenerating] = useState(false)
|
|
|
|
const loadReports = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await api('reports')
|
|
setReports(data || [])
|
|
if (data?.length > 0 && !activeReport) {
|
|
const detail = await api(`reports/${data[0].id}`)
|
|
setActiveReport(detail)
|
|
}
|
|
} catch { /* ignore */ }
|
|
setLoading(false)
|
|
}, [activeReport])
|
|
|
|
useEffect(() => { loadReports() }, [loadReports])
|
|
|
|
const handleGenerate = async () => {
|
|
setGenerating(true)
|
|
try {
|
|
const result = await api('reports/generate', {
|
|
method: 'POST',
|
|
body: JSON.stringify({}),
|
|
})
|
|
setActiveReport(result)
|
|
loadReports()
|
|
} catch { /* ignore */ }
|
|
setGenerating(false)
|
|
}
|
|
|
|
const handleSelectReport = async (id: string) => {
|
|
const detail = await api(`reports/${id}`)
|
|
setActiveReport(detail)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-gray-900">Onboarding-Report</h2>
|
|
<button
|
|
onClick={handleGenerate}
|
|
disabled={generating}
|
|
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
{generating ? 'Generiere...' : 'Neuen Report erstellen'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Report selector */}
|
|
{reports.length > 1 && (
|
|
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
{reports.map(r => (
|
|
<button
|
|
key={r.id}
|
|
onClick={() => handleSelectReport(r.id)}
|
|
className={`px-3 py-1.5 text-xs rounded-lg border whitespace-nowrap ${
|
|
activeReport?.id === r.id
|
|
? 'bg-purple-50 border-purple-300 text-purple-700'
|
|
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
{new Date(r.created_at).toLocaleString('de-DE')} — {r.compliance_score.toFixed(0)}%
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="text-center py-12 text-gray-500">Laden...</div>
|
|
) : !activeReport ? (
|
|
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
|
<p className="text-lg font-medium">Kein Report vorhanden</p>
|
|
<p className="text-sm mt-1">Fuehren Sie zuerst einen Crawl durch und generieren Sie dann einen Report.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{/* Score + Stats */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="flex items-center gap-8">
|
|
<ComplianceRing score={activeReport.compliance_score} />
|
|
<div className="flex-1 grid grid-cols-3 gap-4">
|
|
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
|
<div className="text-3xl font-bold text-gray-900">{activeReport.total_documents_found}</div>
|
|
<div className="text-sm text-gray-500">Dokumente gefunden</div>
|
|
</div>
|
|
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
|
<div className="text-3xl font-bold text-gray-900">
|
|
{Object.keys(activeReport.classification_breakdown || {}).length}
|
|
</div>
|
|
<div className="text-sm text-gray-500">Kategorien abgedeckt</div>
|
|
</div>
|
|
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
|
<div className="text-3xl font-bold text-red-600">
|
|
{(activeReport.gaps || []).length}
|
|
</div>
|
|
<div className="text-sm text-gray-500">Luecken identifiziert</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Classification breakdown */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="font-medium text-gray-900 mb-4">Dokumenten-Verteilung</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{Object.entries(activeReport.classification_breakdown || {}).map(([cat, count]) => {
|
|
const cls = CLASSIFICATION_LABELS[cat] || CLASSIFICATION_LABELS['Sonstiges']
|
|
return (
|
|
<span key={cat} className={`px-3 py-1.5 text-sm font-medium rounded-lg ${cls.color}`}>
|
|
{cls.label}: {count as number}
|
|
</span>
|
|
)
|
|
})}
|
|
{Object.keys(activeReport.classification_breakdown || {}).length === 0 && (
|
|
<span className="text-gray-400 text-sm">Keine Dokumente klassifiziert</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Gap summary */}
|
|
{activeReport.gap_summary && (
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="text-center p-4 bg-red-50 rounded-xl border border-red-100">
|
|
<div className="text-3xl font-bold text-red-600">{activeReport.gap_summary.critical}</div>
|
|
<div className="text-sm text-red-600 font-medium">Kritisch</div>
|
|
</div>
|
|
<div className="text-center p-4 bg-orange-50 rounded-xl border border-orange-100">
|
|
<div className="text-3xl font-bold text-orange-600">{activeReport.gap_summary.high}</div>
|
|
<div className="text-sm text-orange-600 font-medium">Hoch</div>
|
|
</div>
|
|
<div className="text-center p-4 bg-yellow-50 rounded-xl border border-yellow-100">
|
|
<div className="text-3xl font-bold text-yellow-600">{activeReport.gap_summary.medium}</div>
|
|
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gap details */}
|
|
{(activeReport.gaps || []).length > 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="font-medium text-gray-900 mb-4">Compliance-Luecken</h3>
|
|
<div className="space-y-3">
|
|
{activeReport.gaps.map((gap) => (
|
|
<div
|
|
key={gap.id}
|
|
className={`p-4 rounded-lg border-l-4 ${
|
|
gap.severity === 'CRITICAL'
|
|
? 'bg-red-50 border-red-500'
|
|
: gap.severity === 'HIGH'
|
|
? 'bg-orange-50 border-orange-500'
|
|
: 'bg-yellow-50 border-yellow-500'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="font-medium text-gray-900">{gap.category}</div>
|
|
<p className="text-sm text-gray-600 mt-1">{gap.description}</p>
|
|
</div>
|
|
<span className={`px-2 py-1 text-xs font-medium rounded ${
|
|
gap.severity === 'CRITICAL' ? 'bg-red-100 text-red-700'
|
|
: gap.severity === 'HIGH' ? 'bg-orange-100 text-orange-700'
|
|
: 'bg-yellow-100 text-yellow-700'
|
|
}`}>
|
|
{gap.severity}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 text-xs text-gray-500">
|
|
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|