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>
151 lines
6.4 KiB
TypeScript
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>
|
|
)
|
|
}
|