import { useRef, useState, useEffect, useCallback } from "react"; import { useSearchParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { listDocuments, uploadDocument, deleteDocument, downloadDocument, viewDocument, getDocumentStatus, listCategories, createCategory, assignCategory, removeCategory, updateDocumentTitle, reprocessDocument, type DocumentOut, type CategoryOut, type DocumentListParams, } from "../api/client"; function StatusBadge({ status }: { status: DocumentOut["status"] }) { const colors: Record = { pending: "#f4a261", processing: "#2196f3", done: "#2a9d8f", failed: "#e63946", }; return ( {status} ); } // ── Category suggestions ──────────────────────────────────────────────────── interface SuggestionChipProps { name: string; existing: CategoryOut | undefined; onAccept: (name: string, existing: CategoryOut | undefined) => void; onDismiss: (name: string) => void; } function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipProps) { return ( {name} ); } // ── Inline title editor ───────────────────────────────────────────────────── function InlineTitleEditor({ docId, currentTitle, filename, onSaved, }: { docId: string; currentTitle: string | null; filename: string; onSaved: () => void; }) { const [editing, setEditing] = useState(false); const [value, setValue] = useState(currentTitle ?? ""); const saveMut = useMutation({ mutationFn: (t: string) => updateDocumentTitle(docId, t), onSuccess: () => { onSaved(); setEditing(false); }, }); if (!editing) { return ( {currentTitle ?? {filename}} ); } return ( e.stopPropagation()} style={{ display: "inline-flex", alignItems: "center", gap: 6, flex: 1 }}> setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveMut.mutate(value); if (e.key === "Escape") { setEditing(false); setValue(currentTitle ?? ""); } }} style={{ fontSize: 14, padding: "2px 6px", border: "1px solid #888", borderRadius: 3, width: 280 }} autoFocus /> ); } // ── Document row ──────────────────────────────────────────────────────────── function DocumentRow({ doc, categories, onDelete, }: { doc: DocumentOut; categories: CategoryOut[]; onDelete: (id: string) => void; }) { const [expanded, setExpanded] = useState(false); const qc = useQueryClient(); let extractedData: Record | null = null; if (doc.extracted_data) { try { extractedData = JSON.parse(doc.extracted_data); } catch { /* ignore */ } } // Suggested categories from AI — dismissed ones tracked locally const allSuggestions: string[] = Array.isArray(extractedData?.suggested_categories) ? (extractedData!.suggested_categories as string[]) : []; const assignedNames = new Set(doc.categories.map((c) => c.name)); const [dismissed, setDismissed] = useState>(new Set()); const pendingSuggestions = allSuggestions.filter( (s) => !assignedNames.has(s) && !dismissed.has(s) ); // Poll status while pending/processing const { data: liveStatus } = useQuery({ queryKey: ["docStatus", doc.id], queryFn: () => getDocumentStatus(doc.id), refetchInterval: (query) => { const s = query.state.data?.status; return s === "pending" || s === "processing" ? 3000 : false; }, enabled: doc.status === "pending" || doc.status === "processing", }); useEffect(() => { if (liveStatus?.status === "done" || liveStatus?.status === "failed") { qc.invalidateQueries({ queryKey: ["documents"] }); } }, [liveStatus?.status, qc]); const assignMut = useMutation({ mutationFn: ({ catId }: { catId: string }) => assignCategory(doc.id, catId), onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }), }); const removeCatMut = useMutation({ mutationFn: ({ catId }: { catId: string }) => removeCategory(doc.id, catId), onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }), }); const createAndAssignMut = useMutation({ mutationFn: async (name: string) => { const cat = await createCategory(name); await assignCategory(doc.id, cat.id); return cat; }, onSuccess: (_cat, name) => { setDismissed((prev) => new Set([...prev, name])); qc.invalidateQueries({ queryKey: ["documents"] }); qc.invalidateQueries({ queryKey: ["categories"] }); }, }); const reprocessMut = useMutation({ mutationFn: () => reprocessDocument(doc.id), onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }), }); const handleAcceptSuggestion = (name: string, existing: CategoryOut | undefined) => { if (existing) { assignMut.mutate({ catId: existing.id }); setDismissed((prev) => new Set([...prev, name])); } else { createAndAssignMut.mutate(name); } }; const assignedIds = new Set(doc.categories.map((c) => c.id)); const unassigned = categories.filter((c) => !assignedIds.has(c.id)); return (
{/* Row header */}
setExpanded((e) => !e)} > qc.invalidateQueries({ queryKey: ["documents"] })} /> {doc.title && ( {doc.filename} )} {doc.document_type && ( {doc.document_type} )} {(doc.file_size / 1024).toFixed(0)} KB
{/* Expanded detail */} {expanded && (
{/* Extracted fields (excluding internal-only keys) */} {extractedData && (
Extracted data: {Object.entries(extractedData) .filter(([k]) => k !== "tags" && k !== "suggested_categories") .map(([k, v]) => ( ))}
{k} {Array.isArray(v) ? v.length === 0 ? "—" : JSON.stringify(v, null, 2) : v !== null && v !== undefined && v !== "" ? String(v) : "—"}
)} {/* Error */} {doc.error_message && (
Error: {doc.error_message}
)} {/* Assigned categories */}
Categories:{" "} {doc.categories.map((c) => ( {c.name}{" "} ))} {unassigned.length > 0 && ( )}
{/* AI-suggested categories — user must confirm each one */} {pendingSuggestions.length > 0 && (
Suggested by AI:
{pendingSuggestions.map((name) => { const existing = categories.find( (c) => c.name.toLowerCase() === name.toLowerCase() ); return ( setDismissed((prev) => new Set([...prev, n]))} /> ); })}

"Assign" links an existing category · "Create & Assign" creates it first · ✕ dismisses the suggestion

)}
)}
); } // ── Filter bar ────────────────────────────────────────────────────────────── const SORT_OPTIONS = [ { value: "created_at", label: "Upload date" }, { value: "processed_at", label: "Processed date" }, { value: "title", label: "Title" }, { value: "filename", label: "Filename" }, { value: "file_size", label: "File size" }, { value: "document_type", label: "Type" }, { value: "status", label: "Status" }, ]; const STATUS_OPTIONS = ["pending", "processing", "done", "failed"]; const TYPE_OPTIONS = ["invoice", "bill", "receipt", "order", "expense", "revenue", "unknown"]; function FilterBar({ params, activeCategory, onChange, onClearCategory, }: { params: DocumentListParams; activeCategory: CategoryOut | undefined; onChange: (p: Partial) => void; onClearCategory: () => void; }) { const [searchInput, setSearchInput] = useState(params.search ?? ""); useEffect(() => { const id = setTimeout(() => onChange({ search: searchInput || undefined, page: 1 }), 400); return () => clearTimeout(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchInput]); const anyFilterActive = !!(params.search || params.status || params.document_type || params.category_id); return (
{activeCategory && ( Category: {activeCategory.name} )} setSearchInput(e.target.value)} placeholder="Search title, filename…" style={{ padding: "6px 10px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4, width: 220 }} /> {anyFilterActive && ( )}
); } // ── Pagination controls ────────────────────────────────────────────────────── function Pagination({ page, pages, total, perPage, onChange, }: { page: number; pages: number; total: number; perPage: number; onChange: (p: number) => void; }) { const start = (page - 1) * perPage + 1; const end = Math.min(page * perPage, total); return (
{start}–{end} of {total} Page {page} / {pages}
); } // ── Page ──────────────────────────────────────────────────────────────────── export default function DocumentsPage() { const qc = useQueryClient(); const fileRef = useRef(null); const [newCatName, setNewCatName] = useState(""); const [uploadError, setUploadError] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); const categoryIdFromUrl = searchParams.get("category_id") ?? undefined; const [params, setParams] = useState({ page: 1, per_page: 20, sort: "created_at", order: "desc", category_id: categoryIdFromUrl, }); // Sync category_id from URL into params useEffect(() => { setParams((prev) => ({ ...prev, category_id: categoryIdFromUrl, page: 1 })); }, [categoryIdFromUrl]); const updateParams = useCallback((patch: Partial) => { setParams((prev) => ({ ...prev, ...patch })); }, []); const clearCategoryFilter = useCallback(() => { setSearchParams((sp) => { sp.delete("category_id"); return sp; }); }, [setSearchParams]); const { data: docPage, isLoading } = useQuery({ queryKey: ["documents", params], queryFn: () => listDocuments(params), }); const documents = docPage?.items ?? []; const total = docPage?.total ?? 0; const pages = docPage?.pages ?? 1; const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: listCategories, }); const activeCategory = categoryIdFromUrl ? categories.find((c) => c.id === categoryIdFromUrl) : undefined; const uploadMut = useMutation({ mutationFn: uploadDocument, onSuccess: () => { setUploadError(null); qc.invalidateQueries({ queryKey: ["documents"] }); }, onError: (err: unknown) => { const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? "Upload failed"; setUploadError(msg); }, }); const deleteMut = useMutation({ mutationFn: deleteDocument, onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }), }); const createCatMut = useMutation({ mutationFn: createCategory, onSuccess: () => { setNewCatName(""); qc.invalidateQueries({ queryKey: ["categories"] }); }, }); const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) uploadMut.mutate(file); e.target.value = ""; }; return ( <>

Documents

{/* Upload */}
{uploadError && ( {uploadError} )}
{/* Category management */}
Manage categories
{categories.map((c) => ( {c.name} ))}
{ e.preventDefault(); if (newCatName.trim()) createCatMut.mutate(newCatName.trim()); }} > setNewCatName(e.target.value)} placeholder="New category name" style={{ padding: "6px 10px", fontSize: 13 }} />
{/* Filter bar */} {/* Document list */} {isLoading ? (

Loading…

) : documents.length === 0 ? (

{total === 0 && !params.search && !params.status && !params.document_type && !params.category_id ? "No documents yet. Upload a PDF to get started." : "No documents match the current filters."}

) : ( <> {documents.map((doc) => ( deleteMut.mutate(id)} /> ))} {pages > 1 && ( updateParams({ page: p })} /> )} )}
); }