2026-06-05 09:00:36 +08:00
import { useState , useEffect , useCallback } from 'react' ;
2026-06-03 17:45:14 +08:00
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' ;
2026-06-03 17:45:14 +08:00
2026-06-05 18:00:31 +08:00
const TOKEN_KEY = 'auth_token' ;
function authHeader ( ) : Record < string , string > {
const t = localStorage . getItem ( TOKEN_KEY ) ;
return t ? { Authorization : ` Bearer ${ t } ` } : { } ;
}
2026-06-03 17:45:14 +08:00
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-03 17:45:14 +08:00
}
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-03 17:45:14 +08:00
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
}
2026-06-03 17:16:00 +08:00
export function DocsPage() {
2026-06-03 17:45:14 +08:00
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-03 17:45:14 +08:00
2026-06-05 09:00:36 +08:00
const fetchDocs = useCallback ( ( ) = > {
setLoading ( true ) ;
2026-06-05 18:00:31 +08:00
fetch ( '/api/v1/documents/management-list' , { headers : authHeader ( ) } )
2026-06-03 17:45:14 +08:00
. 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-03 17:45:14 +08:00
} , [ ] ) ;
2026-06-05 09:00:36 +08:00
useEffect ( ( ) = > { fetchDocs ( ) ; } , [ fetchDocs , refreshKey ] ) ;
// ── Filtering ────────────────────────────────────────────────────────────
2026-06-03 17:45:14 +08:00
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 ────────────────────────────────────────────────────
2026-06-03 17:45:14 +08:00
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 {
2026-06-05 18:00:31 +08:00
await fetch ( ` /api/v1/documents/ ${ id } /retry ` , { method : 'POST' , headers : authHeader ( ) } ) ;
2026-06-05 09:00:36 +08:00
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 (
2026-06-05 18:00:31 +08:00
ids . map ( id = > fetch ( ` /api/v1/documents/ ${ id } ` , { method : 'DELETE' , headers : authHeader ( ) } ) )
2026-06-05 09:00:36 +08:00
) ;
setDeleting ( new Set ( ) ) ;
setSelected ( s = > { const n = new Set ( s ) ; ids . forEach ( id = > n . delete ( id ) ) ; return n ; } ) ;
setRefreshKey ( k = > k + 1 ) ;
}
2026-06-03 17:45:14 +08:00
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-03 17:45:14 +08:00
< / >
}
/ >
2026-06-05 09:00:36 +08:00
2026-06-03 17:45:14 +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 > ) }
2026-06-03 17:45:14 +08:00
< / select >
< / div >
2026-06-05 09:00:36 +08:00
{ /* Batch action bar */ }
2026-06-03 17:45:14 +08:00
{ 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 >
2026-06-03 17:45:14 +08:00
< / div >
) }
2026-06-05 09:00:36 +08:00
{ /* Table */ }
2026-06-03 17:45:14 +08:00
< 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 >
2026-06-03 17:45:14 +08:00
< 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.' }
2026-06-03 17:45:14 +08:00
< / 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 >
) ;
} )
) }
2026-06-03 17:45:14 +08:00
< / 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 >
) }
2026-06-03 17:45:14 +08:00
< / 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 ) }
/ >
) }
2026-06-03 17:45:14 +08:00
< / div >
) ;
2026-05-14 15:07:34 +08:00
}