Files
Business-Management/frontend/src/pages/DocumentsPage.tsx
T
curo1305 d2042153a7 Add re-analyse button and POST /documents/{id}/reprocess endpoint
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>
2026-04-17 17:00:17 +02:00

756 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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>
</>
);
}