345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Topbar } from '../../components/layout/Topbar';
|
|
import { Upload, Search, Download, Trash2, RefreshCw, AlertTriangle } from 'lucide-react';
|
|
import { UploadModal } from './UploadModal';
|
|
|
|
const TOKEN_KEY = 'auth_token';
|
|
function authHeader(): Record<string, string> {
|
|
const t = localStorage.getItem(TOKEN_KEY);
|
|
return t ? { Authorization: `Bearer ${t}` } : {};
|
|
}
|
|
|
|
interface Doc {
|
|
id: string;
|
|
name: string;
|
|
status: 'ok' | 'warn' | 'risk' | 'info';
|
|
uploadedAt: string;
|
|
chunks: number;
|
|
type: string;
|
|
sizeBytes: number;
|
|
summary?: string;
|
|
version?: string;
|
|
}
|
|
|
|
const STATUS_FILTERS = ['All', 'Ready', 'Processing', 'Failed', 'Pending'];
|
|
const STATUS_LABEL: Record<string, string> = { ok: 'Ready', warn: 'Processing', risk: 'Failed', info: 'Pending' };
|
|
const STATUS_MAP: Record<string, string> = { All: 'All', Ready: 'ok', Processing: 'warn', Failed: 'risk', Pending: 'info' };
|
|
|
|
function backendStatus(s: string): Doc['status'] {
|
|
if (s === 'indexed') return 'ok';
|
|
if (s === 'failed') return 'risk';
|
|
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>
|
|
);
|
|
}
|
|
|
|
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());
|
|
const [docs, setDocs] = useState<Doc[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showUpload, setShowUpload] = useState(false);
|
|
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 !== '—')))];
|
|
|
|
const fetchDocs = useCallback(() => {
|
|
setLoading(true);
|
|
fetch('/api/v1/documents/management-list', { headers: authHeader() })
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
if (!Array.isArray(d?.documents)) { setLoading(false); return; }
|
|
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) || '—',
|
|
sizeBytes: (item.size_bytes as number) ?? 0,
|
|
summary: item.summary as string | undefined,
|
|
version: item.version as string | undefined,
|
|
})));
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, []);
|
|
|
|
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;
|
|
});
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// ── 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', headers: authHeader() });
|
|
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', headers: authHeader() }))
|
|
);
|
|
|
|
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>
|
|
<button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
|
|
<RefreshCw size={13} />Refresh
|
|
</button>
|
|
<button className="btn sm primary" onClick={() => setShowUpload(true)}>
|
|
<Upload size={13} />Upload document
|
|
</button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<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)}>
|
|
{typeOpts.map(o => <option key={o}>{o}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Batch action bar */}
|
|
{selected.size > 0 && (
|
|
<div className="batch-bar">
|
|
<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>
|
|
<button
|
|
className="btn sm"
|
|
style={{ color: 'var(--danger)', borderColor: 'rgba(239,68,68,.4)' }}
|
|
onClick={() => askDelete([...selected])}
|
|
>
|
|
<Trash2 size={12} />Delete selected
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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>
|
|
<span>Size</span>
|
|
<span>Type</span>
|
|
<span>Actions</span>
|
|
</div>
|
|
|
|
{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>
|
|
) : (
|
|
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>
|
|
|
|
{/* 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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|