d2042153a7
Resets status to pending, clears error_message, and re-enqueues the background AI extraction task. Button is disabled while the document is already pending or processing; returns 409 in that case from the API. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
756 lines
25 KiB
TypeScript
756 lines
25 KiB
TypeScript
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<DocumentOut["status"], string> = {
|
||
pending: "#f4a261",
|
||
processing: "#2196f3",
|
||
done: "#2a9d8f",
|
||
failed: "#e63946",
|
||
};
|
||
return (
|
||
<span style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
padding: "2px 8px",
|
||
borderRadius: 4,
|
||
background: colors[status],
|
||
color: "#fff",
|
||
}}>
|
||
{status}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<span style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 5,
|
||
fontSize: 12,
|
||
background: "#fff8e1",
|
||
border: "1px solid #f0c040",
|
||
borderRadius: 4,
|
||
padding: "3px 8px",
|
||
marginRight: 6,
|
||
marginBottom: 4,
|
||
}}>
|
||
<span>{name}</span>
|
||
<button
|
||
onClick={() => onAccept(name, existing)}
|
||
style={{
|
||
fontSize: 11,
|
||
cursor: "pointer",
|
||
padding: "1px 5px",
|
||
background: existing ? "#dce8ff" : "#e8f5e9",
|
||
border: "1px solid #aaa",
|
||
borderRadius: 3,
|
||
}}
|
||
title={existing ? `Assign existing category "${name}"` : `Create and assign "${name}"`}
|
||
>
|
||
{existing ? "Assign" : "Create & Assign"}
|
||
</button>
|
||
<button
|
||
onClick={() => onDismiss(name)}
|
||
style={{ fontSize: 10, cursor: "pointer", background: "none", border: "none", color: "#999" }}
|
||
title="Dismiss suggestion"
|
||
>
|
||
✕
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||
<span style={{ fontWeight: 500 }}>
|
||
{currentTitle ?? <span style={{ color: "#aaa", fontStyle: "italic" }}>{filename}</span>}
|
||
</span>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); setValue(currentTitle ?? ""); setEditing(true); }}
|
||
style={{ fontSize: 11, cursor: "pointer", color: "#888", background: "none", border: "1px solid #ddd", borderRadius: 3, padding: "0 5px" }}
|
||
title="Edit title"
|
||
>
|
||
✎
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<span onClick={(e) => e.stopPropagation()} style={{ display: "inline-flex", alignItems: "center", gap: 6, flex: 1 }}>
|
||
<input
|
||
value={value}
|
||
onChange={(e) => 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
|
||
/>
|
||
<button
|
||
onClick={() => saveMut.mutate(value)}
|
||
disabled={saveMut.isPending}
|
||
style={{ fontSize: 12, cursor: "pointer", padding: "2px 8px", background: "#222", color: "#fff", border: "none", borderRadius: 3 }}
|
||
>
|
||
{saveMut.isPending ? "…" : "Save"}
|
||
</button>
|
||
<button
|
||
onClick={() => { setEditing(false); setValue(currentTitle ?? ""); }}
|
||
style={{ fontSize: 12, cursor: "pointer", padding: "2px 6px", border: "1px solid #ccc", borderRadius: 3, background: "none" }}
|
||
>
|
||
✕
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ── 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<string, unknown> | 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<Set<string>>(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 (
|
||
<div style={{ border: "1px solid #ddd", borderRadius: 6, marginBottom: 12 }}>
|
||
{/* Row header */}
|
||
<div
|
||
style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 16px", cursor: "pointer" }}
|
||
onClick={() => setExpanded((e) => !e)}
|
||
>
|
||
<span style={{ flex: 1 }}>
|
||
<InlineTitleEditor
|
||
docId={doc.id}
|
||
currentTitle={doc.title}
|
||
filename={doc.filename}
|
||
onSaved={() => qc.invalidateQueries({ queryKey: ["documents"] })}
|
||
/>
|
||
{doc.title && (
|
||
<span style={{ display: "block", fontSize: 11, color: "#999", marginTop: 1 }}>
|
||
{doc.filename}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<StatusBadge status={doc.status} />
|
||
{doc.document_type && (
|
||
<span style={{ fontSize: 12, color: "#555" }}>{doc.document_type}</span>
|
||
)}
|
||
<span style={{ fontSize: 12, color: "#999" }}>
|
||
{(doc.file_size / 1024).toFixed(0)} KB
|
||
</span>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); viewDocument(doc.id); }}
|
||
style={{ fontSize: 12, cursor: "pointer" }}
|
||
>
|
||
View
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); downloadDocument(doc.id, doc.filename); }}
|
||
style={{ fontSize: 12, cursor: "pointer" }}
|
||
>
|
||
Download
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
reprocessMut.mutate();
|
||
}}
|
||
disabled={reprocessMut.isPending || doc.status === "pending" || doc.status === "processing"}
|
||
style={{
|
||
fontSize: 12,
|
||
cursor: reprocessMut.isPending || doc.status === "pending" || doc.status === "processing" ? "default" : "pointer",
|
||
color: "#2a7ae2",
|
||
opacity: reprocessMut.isPending || doc.status === "pending" || doc.status === "processing" ? 0.5 : 1,
|
||
}}
|
||
title="Re-run AI analysis on this document"
|
||
>
|
||
{reprocessMut.isPending ? "…" : "Re-analyse"}
|
||
</button>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (confirm(`Delete "${doc.filename}"?`)) onDelete(doc.id);
|
||
}}
|
||
style={{ fontSize: 12, color: "#c00", cursor: "pointer" }}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
|
||
{/* Expanded detail */}
|
||
{expanded && (
|
||
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #eee" }}>
|
||
|
||
{/* Extracted fields (excluding internal-only keys) */}
|
||
{extractedData && (
|
||
<div style={{ marginTop: 10 }}>
|
||
<strong>Extracted data:</strong>
|
||
<table style={{ marginTop: 6, fontSize: 13, borderCollapse: "collapse" }}>
|
||
<tbody>
|
||
{Object.entries(extractedData)
|
||
.filter(([k]) => k !== "tags" && k !== "suggested_categories")
|
||
.map(([k, v]) => (
|
||
<tr key={k}>
|
||
<td style={{ paddingRight: 16, color: "#666", verticalAlign: "top" }}>{k}</td>
|
||
<td>
|
||
{Array.isArray(v)
|
||
? v.length === 0 ? "—" : JSON.stringify(v, null, 2)
|
||
: v !== null && v !== undefined && v !== "" ? String(v) : "—"}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{doc.error_message && (
|
||
<div style={{ marginTop: 10, color: "#c00", fontSize: 13 }}>
|
||
Error: {doc.error_message}
|
||
</div>
|
||
)}
|
||
|
||
{/* Assigned categories */}
|
||
<div style={{ marginTop: 12 }}>
|
||
<strong style={{ fontSize: 13 }}>Categories:</strong>{" "}
|
||
{doc.categories.map((c) => (
|
||
<span key={c.id} style={{ fontSize: 12, background: "#dce8ff", borderRadius: 3, padding: "2px 6px", marginRight: 4 }}>
|
||
{c.name}{" "}
|
||
<button
|
||
onClick={() => removeCatMut.mutate({ catId: c.id })}
|
||
style={{ fontSize: 10, cursor: "pointer", color: "#555", background: "none", border: "none" }}
|
||
>
|
||
x
|
||
</button>
|
||
</span>
|
||
))}
|
||
{unassigned.length > 0 && (
|
||
<select
|
||
defaultValue=""
|
||
onChange={(e) => {
|
||
if (e.target.value) assignMut.mutate({ catId: e.target.value });
|
||
e.target.value = "";
|
||
}}
|
||
style={{ fontSize: 12, marginLeft: 4 }}
|
||
>
|
||
<option value="">+ add category</option>
|
||
{unassigned.map((c) => (
|
||
<option key={c.id} value={c.id}>{c.name}</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
|
||
{/* AI-suggested categories — user must confirm each one */}
|
||
{pendingSuggestions.length > 0 && (
|
||
<div style={{ marginTop: 12 }}>
|
||
<strong style={{ fontSize: 13 }}>Suggested by AI:</strong>
|
||
<div style={{ marginTop: 6 }}>
|
||
{pendingSuggestions.map((name) => {
|
||
const existing = categories.find(
|
||
(c) => c.name.toLowerCase() === name.toLowerCase()
|
||
);
|
||
return (
|
||
<SuggestionChip
|
||
key={name}
|
||
name={name}
|
||
existing={existing}
|
||
onAccept={handleAcceptSuggestion}
|
||
onDismiss={(n) => setDismissed((prev) => new Set([...prev, n]))}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
<p style={{ fontSize: 11, color: "#999", margin: "4px 0 0" }}>
|
||
"Assign" links an existing category · "Create & Assign" creates it first · ✕ dismisses the suggestion
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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<DocumentListParams>) => 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 (
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 16, alignItems: "center" }}>
|
||
{activeCategory && (
|
||
<span style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
fontSize: 13,
|
||
background: "#dce8ff",
|
||
border: "1px solid #a0b8e8",
|
||
borderRadius: 4,
|
||
padding: "4px 10px",
|
||
}}>
|
||
Category: <strong>{activeCategory.name}</strong>
|
||
<button
|
||
onClick={onClearCategory}
|
||
style={{ fontSize: 11, cursor: "pointer", background: "none", border: "none", color: "#555", lineHeight: 1 }}
|
||
title="Remove category filter"
|
||
>
|
||
✕
|
||
</button>
|
||
</span>
|
||
)}
|
||
|
||
<input
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
placeholder="Search title, filename…"
|
||
style={{ padding: "6px 10px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4, width: 220 }}
|
||
/>
|
||
|
||
<select
|
||
value={params.status ?? ""}
|
||
onChange={(e) => onChange({ status: e.target.value || undefined, page: 1 })}
|
||
style={{ padding: "6px 8px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4 }}
|
||
>
|
||
<option value="">All statuses</option>
|
||
{STATUS_OPTIONS.map((s) => (
|
||
<option key={s} value={s}>{s}</option>
|
||
))}
|
||
</select>
|
||
|
||
<select
|
||
value={params.document_type ?? ""}
|
||
onChange={(e) => onChange({ document_type: e.target.value || undefined, page: 1 })}
|
||
style={{ padding: "6px 8px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4 }}
|
||
>
|
||
<option value="">All types</option>
|
||
{TYPE_OPTIONS.map((t) => (
|
||
<option key={t} value={t}>{t}</option>
|
||
))}
|
||
</select>
|
||
|
||
<select
|
||
value={params.sort ?? "created_at"}
|
||
onChange={(e) => onChange({ sort: e.target.value, page: 1 })}
|
||
style={{ padding: "6px 8px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4 }}
|
||
>
|
||
{SORT_OPTIONS.map((o) => (
|
||
<option key={o.value} value={o.value}>{o.label}</option>
|
||
))}
|
||
</select>
|
||
|
||
<button
|
||
onClick={() => onChange({ order: params.order === "asc" ? "desc" : "asc", page: 1 })}
|
||
title={params.order === "asc" ? "Ascending — click to reverse" : "Descending — click to reverse"}
|
||
style={{ padding: "6px 10px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4, cursor: "pointer", background: "#fff" }}
|
||
>
|
||
{params.order === "asc" ? "↑ Asc" : "↓ Desc"}
|
||
</button>
|
||
|
||
{anyFilterActive && (
|
||
<button
|
||
onClick={() => {
|
||
setSearchInput("");
|
||
onChange({ search: undefined, status: undefined, document_type: undefined, page: 1 });
|
||
onClearCategory();
|
||
}}
|
||
style={{ padding: "6px 10px", fontSize: 12, border: "1px solid #ddd", borderRadius: 4, cursor: "pointer", color: "#666", background: "#fafafa" }}
|
||
>
|
||
Clear filters
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 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 (
|
||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 16, fontSize: 13, color: "#555" }}>
|
||
<button
|
||
onClick={() => onChange(page - 1)}
|
||
disabled={page <= 1}
|
||
style={{ padding: "4px 10px", cursor: page > 1 ? "pointer" : "default", borderRadius: 4, border: "1px solid #ccc", background: "#fff" }}
|
||
>
|
||
‹ Prev
|
||
</button>
|
||
<span>
|
||
{start}–{end} of {total}
|
||
</span>
|
||
<button
|
||
onClick={() => onChange(page + 1)}
|
||
disabled={page >= pages}
|
||
style={{ padding: "4px 10px", cursor: page < pages ? "pointer" : "default", borderRadius: 4, border: "1px solid #ccc", background: "#fff" }}
|
||
>
|
||
Next ›
|
||
</button>
|
||
<span style={{ color: "#aaa" }}>Page {page} / {pages}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Page ────────────────────────────────────────────────────────────────────
|
||
|
||
export default function DocumentsPage() {
|
||
const qc = useQueryClient();
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
const [newCatName, setNewCatName] = useState("");
|
||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
const categoryIdFromUrl = searchParams.get("category_id") ?? undefined;
|
||
|
||
const [params, setParams] = useState<DocumentListParams>({
|
||
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<DocumentListParams>) => {
|
||
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<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) uploadMut.mutate(file);
|
||
e.target.value = "";
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div style={{ padding: 32, maxWidth: 960, margin: "0 auto" }}>
|
||
<h1>Documents</h1>
|
||
|
||
{/* Upload */}
|
||
<div style={{ marginBottom: 24 }}>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="application/pdf"
|
||
style={{ display: "none" }}
|
||
onChange={handleFileChange}
|
||
/>
|
||
<button
|
||
onClick={() => fileRef.current?.click()}
|
||
disabled={uploadMut.isPending}
|
||
style={{ padding: "8px 16px", cursor: "pointer" }}
|
||
>
|
||
{uploadMut.isPending ? "Uploading…" : "Upload PDF"}
|
||
</button>
|
||
{uploadError && (
|
||
<span style={{ marginLeft: 12, color: "#c00", fontSize: 13 }}>{uploadError}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Category management */}
|
||
<details style={{ marginBottom: 24 }}>
|
||
<summary style={{ cursor: "pointer", fontWeight: 500 }}>Manage categories</summary>
|
||
<div style={{ marginTop: 10, display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||
{categories.map((c) => (
|
||
<span key={c.id} style={{ fontSize: 13, background: "#eee", borderRadius: 4, padding: "4px 10px" }}>
|
||
{c.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<form
|
||
style={{ marginTop: 10, display: "flex", gap: 8 }}
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
if (newCatName.trim()) createCatMut.mutate(newCatName.trim());
|
||
}}
|
||
>
|
||
<input
|
||
value={newCatName}
|
||
onChange={(e) => setNewCatName(e.target.value)}
|
||
placeholder="New category name"
|
||
style={{ padding: "6px 10px", fontSize: 13 }}
|
||
/>
|
||
<button type="submit" disabled={createCatMut.isPending} style={{ cursor: "pointer" }}>
|
||
Add
|
||
</button>
|
||
</form>
|
||
</details>
|
||
|
||
{/* Filter bar */}
|
||
<FilterBar
|
||
params={params}
|
||
activeCategory={activeCategory}
|
||
onChange={updateParams}
|
||
onClearCategory={clearCategoryFilter}
|
||
/>
|
||
|
||
{/* Document list */}
|
||
{isLoading ? (
|
||
<p>Loading…</p>
|
||
) : documents.length === 0 ? (
|
||
<p style={{ color: "#666" }}>
|
||
{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."}
|
||
</p>
|
||
) : (
|
||
<>
|
||
{documents.map((doc) => (
|
||
<DocumentRow
|
||
key={doc.id}
|
||
doc={doc}
|
||
categories={categories}
|
||
onDelete={(id) => deleteMut.mutate(id)}
|
||
/>
|
||
))}
|
||
{pages > 1 && (
|
||
<Pagination
|
||
page={params.page ?? 1}
|
||
pages={pages}
|
||
total={total}
|
||
perPage={params.per_page ?? 20}
|
||
onChange={(p) => updateParams({ page: p })}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|