refactor(admin): split document-crawler page.tsx into colocated components
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>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user