Files
breakpilot-compliance/admin-compliance/app/sdk/document-crawler/_components/DocumentsTab.tsx
Sharang Parnerkar e6ff76d0e1 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>
2026-04-15 08:18:59 +02:00

151 lines
6.4 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { CrawlDocument, api, CLASSIFICATION_LABELS, ALL_CLASSIFICATIONS } from '../_types'
export function DocumentsTab() {
const [docs, setDocs] = useState<CrawlDocument[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [filterClass, setFilterClass] = useState('')
const [archiving, setArchiving] = useState<Record<string, boolean>>({})
const loadDocs = useCallback(async () => {
setLoading(true)
try {
const params = filterClass ? `?classification=${filterClass}` : ''
const data = await api(`documents${params}`)
setDocs(data?.documents || [])
setTotal(data?.total || 0)
} catch { /* ignore */ }
setLoading(false)
}, [filterClass])
useEffect(() => { loadDocs() }, [loadDocs])
const handleReclassify = async (docId: string, newClass: string) => {
await api(`documents/${docId}/classify`, {
method: 'PUT',
body: JSON.stringify({ classification: newClass }),
})
loadDocs()
}
const handleArchive = async (docId: string) => {
setArchiving(prev => ({ ...prev, [docId]: true }))
try {
await api(`documents/${docId}/archive`, { method: 'POST' })
loadDocs()
} catch { /* ignore */ }
setArchiving(prev => ({ ...prev, [docId]: false }))
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">{total} Dokumente</h2>
<select
value={filterClass}
onChange={e => setFilterClass(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Alle Kategorien</option>
{ALL_CLASSIFICATIONS.map(c => (
<option key={c} value={c}>{CLASSIFICATION_LABELS[c]?.label || c}</option>
))}
</select>
</div>
{loading ? (
<div className="text-center py-12 text-gray-500">Laden...</div>
) : docs.length === 0 ? (
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
Keine Dokumente gefunden. Starten Sie zuerst einen Crawl-Job.
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="text-left px-4 py-3 font-medium">Datei</th>
<th className="text-left px-4 py-3 font-medium">Kategorie</th>
<th className="text-center px-4 py-3 font-medium">Konfidenz</th>
<th className="text-right px-4 py-3 font-medium">Groesse</th>
<th className="text-center px-4 py-3 font-medium">Archiv</th>
<th className="text-right px-4 py-3 font-medium">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{docs.map(doc => {
const cls = CLASSIFICATION_LABELS[doc.classification || ''] || CLASSIFICATION_LABELS['Sonstiges']
return (
<tr key={doc.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900 truncate max-w-xs">{doc.file_name}</div>
<div className="text-xs text-gray-400">{doc.source_name}</div>
</td>
<td className="px-4 py-3">
<select
value={doc.classification || 'Sonstiges'}
onChange={e => handleReclassify(doc.id, e.target.value)}
className={`px-2 py-1 text-xs font-medium rounded border-0 ${cls.color}`}
>
{ALL_CLASSIFICATIONS.map(c => (
<option key={c} value={c}>{CLASSIFICATION_LABELS[c].label}</option>
))}
</select>
{doc.classification_corrected && (
<span className="ml-1 text-xs text-orange-500" title="Manuell korrigiert">*</span>
)}
</td>
<td className="px-4 py-3 text-center">
{doc.classification_confidence != null && (
<div className="inline-flex items-center gap-1">
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 rounded-full"
style={{ width: `${doc.classification_confidence * 100}%` }}
/>
</div>
<span className="text-xs text-gray-500">
{(doc.classification_confidence * 100).toFixed(0)}%
</span>
</div>
)}
</td>
<td className="px-4 py-3 text-right text-gray-500">{formatSize(doc.file_size_bytes)}</td>
<td className="px-4 py-3 text-center">
{doc.archived ? (
<span className="text-green-600 text-xs font-medium" title={doc.ipfs_cid || ''}>IPFS</span>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</td>
<td className="px-4 py-3 text-right">
{!doc.archived && (
<button
onClick={() => handleArchive(doc.id)}
disabled={archiving[doc.id]}
className="text-xs text-purple-600 hover:text-purple-800 disabled:opacity-50"
>
{archiving[doc.id] ? 'Archiviert...' : 'Archivieren'}
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}