Files
AIRegulation-DocAnalysis/frontend/src/pages/Docs/DocsPage.tsx

339 lines
13 KiB
TypeScript
Raw Normal View History

2026-06-05 09:00:36 +08:00
import { useState, useEffect, useCallback } from 'react';
import { Topbar } from '../../components/layout/Topbar';
2026-06-05 09:00:36 +08:00
import { Upload, Search, Download, Trash2, RefreshCw, AlertTriangle } from 'lucide-react';
2026-06-04 15:43:44 +08:00
import { UploadModal } from './UploadModal';
interface Doc {
id: string;
name: string;
status: 'ok' | 'warn' | 'risk' | 'info';
uploadedAt: string;
chunks: number;
type: string;
2026-06-05 09:00:36 +08:00
sizeBytes: number;
summary?: string;
version?: string;
}
2026-06-05 09:00:36 +08:00
const STATUS_FILTERS = ['All', 'Ready', 'Processing', 'Failed', 'Pending'];
2026-06-04 15:43:44 +08:00
const STATUS_LABEL: Record<string, string> = { ok: 'Ready', warn: 'Processing', risk: 'Failed', info: 'Pending' };
2026-06-05 09:00:36 +08:00
const STATUS_MAP: Record<string, string> = { All: 'All', Ready: 'ok', Processing: 'warn', Failed: 'risk', Pending: 'info' };
2026-06-04 15:43:44 +08:00
function backendStatus(s: string): Doc['status'] {
if (s === 'indexed') return 'ok';
if (s === 'failed') return 'risk';
2026-06-05 09:00:36 +08:00
if (s === 'parsed') return 'warn';
return 'info';
}
function formatSize(bytes: number): string {
if (!bytes) return '—';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
// ── Confirm dialog ─────────────────────────────────────────────────────────
function ConfirmDialog({ message, onConfirm, onCancel }: {
message: string;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<div className="modal-overlay" onClick={onCancel}>
<div
style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', maxWidth: 400, width: '100%', boxShadow: '0 12px 40px rgba(0,0,0,.2)' }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<AlertTriangle size={18} color="var(--danger)" />
<span style={{ fontWeight: 600, fontSize: 15 }}>Confirm deletion</span>
</div>
<p style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 20 }}>{message}</p>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button className="btn sm" onClick={onCancel}>Cancel</button>
<button className="btn sm" style={{ background: 'var(--danger)', color: '#fff', borderColor: 'var(--danger)' }} onClick={onConfirm}>
Delete
</button>
</div>
</div>
</div>
);
2026-06-04 15:43:44 +08:00
}
export function DocsPage() {
const [search, setSearch] = useState('');
const [statusF, setStatusF] = useState('All');
const [typeF, setTypeF] = useState('All types');
const [selected, setSelected] = useState<Set<string>>(new Set());
2026-06-05 09:00:36 +08:00
const [docs, setDocs] = useState<Doc[]>([]);
const [loading, setLoading] = useState(true);
2026-06-04 15:43:44 +08:00
const [showUpload, setShowUpload] = useState(false);
2026-06-05 09:00:36 +08:00
const [refreshKey, setRefreshKey] = useState(0);
const [retrying, setRetrying] = useState<Set<string>>(new Set());
const [deleting, setDeleting] = useState<Set<string>>(new Set());
const [confirmDelete, setConfirmDelete] = useState<{ ids: string[]; names: string[] } | null>(null);
// Dynamic type options derived from actual docs
const typeOpts = ['All types', ...Array.from(new Set(docs.map(d => d.type).filter(t => t && t !== '—')))];
2026-06-05 09:00:36 +08:00
const fetchDocs = useCallback(() => {
setLoading(true);
2026-06-04 15:43:44 +08:00
fetch('/api/v1/documents/management-list')
.then(r => r.json())
2026-06-04 15:43:44 +08:00
.then(d => {
2026-06-05 09:00:36 +08:00
if (!Array.isArray(d?.documents)) { setLoading(false); return; }
2026-06-04 15:43:44 +08:00
setDocs(d.documents.map((item: Record<string, unknown>) => ({
id: item.doc_id as string,
name: item.doc_name as string,
status: backendStatus(item.status as string),
uploadedAt: ((item.updated_at as string) ?? '').slice(0, 10),
chunks: (item.chunk_count as number) ?? 0,
type: (item.regulation_type as string) || '—',
2026-06-05 09:00:36 +08:00
sizeBytes: (item.size_bytes as number) ?? 0,
summary: item.summary as string | undefined,
version: item.version as string | undefined,
2026-06-04 15:43:44 +08:00
})));
2026-06-05 09:00:36 +08:00
setLoading(false);
2026-06-04 15:43:44 +08:00
})
2026-06-05 09:00:36 +08:00
.catch(() => setLoading(false));
}, []);
2026-06-05 09:00:36 +08:00
useEffect(() => { fetchDocs(); }, [fetchDocs, refreshKey]);
// ── Filtering ────────────────────────────────────────────────────────────
const filtered = docs.filter(d => {
const matchSearch = !search || d.name.toLowerCase().includes(search.toLowerCase());
const matchStatus = statusF === 'All' || d.status === STATUS_MAP[statusF];
const matchType = typeF === 'All types' || d.type === typeF;
return matchSearch && matchStatus && matchType;
});
2026-06-05 09:00:36 +08:00
// ── Selection helpers ────────────────────────────────────────────────────
function toggleAll() {
if (selected.size === filtered.length) setSelected(new Set());
else setSelected(new Set(filtered.map(d => d.id)));
}
function toggleOne(id: string) {
const s = new Set(selected);
s.has(id) ? s.delete(id) : s.add(id);
setSelected(s);
}
2026-06-05 09:00:36 +08:00
// ── Download ─────────────────────────────────────────────────────────────
function downloadDoc(id: string, name: string) {
const a = document.createElement('a');
a.href = `/api/v1/documents/download/${id}`;
a.download = name;
a.click();
}
// ── Retry (re-process failed doc) ────────────────────────────────────────
async function retryDoc(id: string) {
setRetrying(r => new Set([...r, id]));
try {
await fetch(`/api/v1/documents/${id}/retry`, { method: 'POST' });
setTimeout(() => {
setRetrying(r => { const s = new Set(r); s.delete(id); return s; });
setRefreshKey(k => k + 1);
}, 1500);
} catch {
setRetrying(r => { const s = new Set(r); s.delete(id); return s; });
}
}
// ── Delete (single or batch) ─────────────────────────────────────────────
function askDelete(ids: string[]) {
const names = ids.map(id => docs.find(d => d.id === id)?.name ?? id);
setConfirmDelete({ ids, names });
}
async function confirmDeleteDocs() {
if (!confirmDelete) return;
const { ids } = confirmDelete;
setConfirmDelete(null);
setDeleting(new Set(ids));
await Promise.allSettled(
ids.map(id => fetch(`/api/v1/documents/${id}`, { method: 'DELETE' }))
);
setDeleting(new Set());
setSelected(s => { const n = new Set(s); ids.forEach(id => n.delete(id)); return n; });
setRefreshKey(k => k + 1);
}
return (
<div className="docs-page">
<Topbar
title="Document Management"
actions={
<>
<div className="search-box">
<Search size={13} />
<input
placeholder="Search documents..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
2026-06-05 09:00:36 +08:00
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
<RefreshCw size={13} />Refresh
</button>
2026-06-04 15:43:44 +08:00
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
<Upload size={13} />Upload document
</button>
</>
}
/>
2026-06-05 09:00:36 +08:00
<div className="page-content">
<div className="docs-controls">
<div className="chip-group">
{STATUS_FILTERS.map(f => (
<button
key={f}
className={`chip${statusF === f ? ' active' : ''}`}
onClick={() => setStatusF(f)}
>{f}</button>
))}
</div>
<select className="select-input" value={typeF} onChange={e => setTypeF(e.target.value)}>
2026-06-05 09:00:36 +08:00
{typeOpts.map(o => <option key={o}>{o}</option>)}
</select>
</div>
2026-06-05 09:00:36 +08:00
{/* Batch action bar */}
{selected.size > 0 && (
<div className="batch-bar">
<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>
2026-06-05 09:00:36 +08:00
<button
className="btn sm"
style={{ color: 'var(--danger)', borderColor: 'rgba(239,68,68,.4)' }}
onClick={() => askDelete([...selected])}
>
<Trash2 size={12} />Delete selected
</button>
</div>
)}
2026-06-05 09:00:36 +08:00
{/* Table */}
<div className="docs-table">
<div className="table-header">
<input
type="checkbox"
checked={selected.size === filtered.length && filtered.length > 0}
onChange={toggleAll}
/>
<span>Document name</span>
<span>Status</span>
<span>Uploaded</span>
<span>Chunks</span>
2026-06-05 09:00:36 +08:00
<span>Size</span>
<span>Type</span>
<span>Actions</span>
</div>
2026-06-05 09:00:36 +08:00
{loading ? (
<div style={{ padding: '32px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
Loading documents
</div>
) : filtered.length === 0 ? (
<div style={{ padding: '40px 16px', color: 'var(--muted)', fontSize: 13, textAlign: 'center' }}>
{docs.length === 0 ? 'No documents yet. Upload a document to get started.' : 'No documents match the current filters.'}
</div>
2026-06-05 09:00:36 +08:00
) : (
filtered.map(d => {
const isDeleting = deleting.has(d.id);
const isRetrying = retrying.has(d.id);
return (
<div
key={d.id}
className={`table-row${selected.has(d.id) ? ' row-selected' : ''}${isDeleting ? ' row-deleting' : ''}`}
>
<input
type="checkbox"
checked={selected.has(d.id)}
onChange={() => toggleOne(d.id)}
disabled={isDeleting}
/>
<span className="doc-name-cell" title={d.summary || d.name}>
{d.name}
{d.version && <span style={{ fontSize: 10, color: 'var(--muted)', marginLeft: 6 }}>v{d.version}</span>}
</span>
<span><span className={`status ${d.status}`}>{STATUS_LABEL[d.status]}</span></span>
<span className="cell-mono">{d.uploadedAt}</span>
<span className="cell-mono">{d.chunks || '—'}</span>
<span className="cell-mono">{formatSize(d.sizeBytes)}</span>
<span className="cell-muted">{d.type}</span>
<span className="row-actions">
{/* Download */}
<button
className="text-link"
title="Download original file"
onClick={() => downloadDoc(d.id, d.name)}
>
<Download size={12} />
</button>
{/* Retry for failed */}
{d.status === 'risk' && (
<button
className="text-link"
title="Retry processing"
disabled={isRetrying}
onClick={() => retryDoc(d.id)}
style={{ color: 'var(--warn)' }}
>
<RefreshCw size={12} style={{ animation: isRetrying ? 'spin 1s linear infinite' : 'none' }} />
</button>
)}
{/* Delete */}
<button
className="text-link danger-link"
title="Delete document"
disabled={isDeleting}
onClick={() => askDelete([d.id])}
>
<Trash2 size={12} />
</button>
</span>
</div>
);
})
)}
</div>
2026-06-05 09:00:36 +08:00
{/* Footer count */}
{!loading && (
<div style={{ padding: '10px 0', fontSize: 12, color: 'var(--muted)' }}>
{filtered.length} of {docs.length} document{docs.length !== 1 ? 's' : ''}
{selected.size > 0 && ` · ${selected.size} selected`}
</div>
)}
</div>
2026-06-04 15:43:44 +08:00
2026-06-05 09:00:36 +08:00
{/* Confirm delete dialog */}
{confirmDelete && (
<ConfirmDialog
message={
confirmDelete.ids.length === 1
? `Delete "${confirmDelete.names[0]}"? This will remove the document, all its chunks, and embeddings from the vector store. This action cannot be undone.`
: `Delete ${confirmDelete.ids.length} documents? This will remove them and all their chunks from the vector store. This action cannot be undone.`
}
onConfirm={confirmDeleteDocs}
onCancel={() => setConfirmDelete(null)}
/>
)}
{showUpload && (
<UploadModal
onClose={() => setShowUpload(false)}
onComplete={() => setRefreshKey(k => k + 1)}
/>
)}
</div>
);
2026-05-14 15:07:34 +08:00
}