Add sidebar app sub-nav with categories, category filter, and re-analysis on category creation
- Sidebar: Apps accordion expands to Documents, which expands to list all user categories; clicking a category navigates to /apps/documents?category_id=<id> - DocumentsPage: reads category_id from URL and applies filter; shows active category chip in FilterBar with dismiss; removed TagEditor (deferred) - doc-service GET /documents: new category_id query param filters via subquery - doc-service POST /documents/categories: detects similar category names and triggers background re-analysis of affected documents so the new category surfaces as a pending AI suggestion on relevant docs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
listDocuments,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
createCategory,
|
||||
assignCategory,
|
||||
removeCategory,
|
||||
updateDocumentTags,
|
||||
updateDocumentTitle,
|
||||
type DocumentOut,
|
||||
type CategoryOut,
|
||||
@@ -88,8 +88,6 @@ function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipP
|
||||
);
|
||||
}
|
||||
|
||||
// ── Document row ────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Inline title editor ─────────────────────────────────────────────────────
|
||||
|
||||
function InlineTitleEditor({
|
||||
@@ -157,108 +155,6 @@ function InlineTitleEditor({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tag editor ──────────────────────────────────────────────────────────────
|
||||
|
||||
function TagEditor({
|
||||
docId,
|
||||
initialTags,
|
||||
onSaved,
|
||||
}: {
|
||||
docId: string;
|
||||
initialTags: string[];
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [tags, setTags] = useState<string[]>(initialTags);
|
||||
const [input, setInput] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (t: string[]) => updateDocumentTags(docId, t),
|
||||
onSuccess: () => { onSaved(); setEditing(false); },
|
||||
});
|
||||
|
||||
const addTag = () => {
|
||||
const val = input.trim();
|
||||
if (!val || tags.map((t) => t.toLowerCase()).includes(val.toLowerCase())) {
|
||||
setInput("");
|
||||
return;
|
||||
}
|
||||
const next = [...tags, val];
|
||||
setTags(next);
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const removeTag = (idx: number) => {
|
||||
setTags((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<strong>Tags:</strong>{" "}
|
||||
{tags.length === 0 && <span style={{ color: "#aaa", fontSize: 13 }}>none</span>}
|
||||
{tags.map((t) => (
|
||||
<span key={t} style={{ fontSize: 12, background: "#eee", borderRadius: 3, padding: "2px 6px", marginRight: 4 }}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
style={{ fontSize: 11, marginLeft: 6, cursor: "pointer", color: "#555", background: "none", border: "1px solid #ccc", borderRadius: 3, padding: "1px 6px" }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<strong>Tags:</strong>
|
||||
<div style={{ marginTop: 6, display: "flex", flexWrap: "wrap", gap: 6, alignItems: "center" }}>
|
||||
{tags.map((t, i) => (
|
||||
<span key={t} style={{ fontSize: 12, background: "#eee", borderRadius: 3, padding: "2px 6px", display: "inline-flex", alignItems: "center", gap: 4 }}>
|
||||
{t}
|
||||
<button
|
||||
onClick={() => removeTag(i)}
|
||||
style={{ fontSize: 10, cursor: "pointer", background: "none", border: "none", color: "#888", lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addTag(); }
|
||||
if (e.key === "Escape") { setEditing(false); setTags(initialTags); setInput(""); }
|
||||
}}
|
||||
placeholder="Add tag…"
|
||||
style={{ fontSize: 12, padding: "2px 6px", border: "1px solid #ccc", borderRadius: 3, width: 100 }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, display: "flex", gap: 6 }}>
|
||||
<button
|
||||
onClick={() => saveMut.mutate(tags)}
|
||||
disabled={saveMut.isPending}
|
||||
style={{ fontSize: 12, cursor: "pointer", padding: "3px 10px", background: "#222", color: "#fff", border: "none", borderRadius: 3 }}
|
||||
>
|
||||
{saveMut.isPending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditing(false); setTags(initialTags); setInput(""); }}
|
||||
style={{ fontSize: 12, cursor: "pointer", padding: "3px 10px", border: "1px solid #ccc", borderRadius: 3, background: "none" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "#999", margin: "4px 0 0" }}>Enter or comma to add · Esc to cancel</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Document row ────────────────────────────────────────────────────────────
|
||||
|
||||
function DocumentRow({
|
||||
@@ -273,28 +169,18 @@ function DocumentRow({
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Parse extracted_data once
|
||||
let extractedData: Record<string, unknown> | null = null;
|
||||
if (doc.extracted_data) {
|
||||
try { extractedData = JSON.parse(doc.extracted_data); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const tags: string[] = [];
|
||||
if (doc.tags) {
|
||||
try {
|
||||
const parsed = JSON.parse(doc.tags);
|
||||
if (Array.isArray(parsed)) tags.push(...parsed);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Suggested categories from AI — dismissed ones are tracked locally
|
||||
// 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());
|
||||
|
||||
// Only show suggestions that haven't been assigned yet and haven't been dismissed
|
||||
const pendingSuggestions = allSuggestions.filter(
|
||||
(s) => !assignedNames.has(s) && !dismissed.has(s)
|
||||
);
|
||||
@@ -405,13 +291,6 @@ function DocumentRow({
|
||||
{expanded && (
|
||||
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #eee" }}>
|
||||
|
||||
{/* Tags — always shown, editable */}
|
||||
<TagEditor
|
||||
docId={doc.id}
|
||||
initialTags={tags}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ["documents"] })}
|
||||
/>
|
||||
|
||||
{/* Extracted fields (excluding internal-only keys) */}
|
||||
{extractedData && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
@@ -473,7 +352,7 @@ function DocumentRow({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI-suggested categories */}
|
||||
{/* AI-suggested categories — user must confirm each one */}
|
||||
{pendingSuggestions.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<strong style={{ fontSize: 13 }}>Suggested by AI:</strong>
|
||||
@@ -521,26 +400,53 @@ const TYPE_OPTIONS = ["invoice", "bill", "receipt", "order", "expense", "revenue
|
||||
|
||||
function FilterBar({
|
||||
params,
|
||||
activeCategory,
|
||||
onChange,
|
||||
onClearCategory,
|
||||
}: {
|
||||
params: DocumentListParams;
|
||||
activeCategory: CategoryOut | undefined;
|
||||
onChange: (p: Partial<DocumentListParams>) => void;
|
||||
onClearCategory: () => void;
|
||||
}) {
|
||||
const [searchInput, setSearchInput] = useState(params.search ?? "");
|
||||
|
||||
// Debounce search: commit after 400 ms of no typing
|
||||
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, tags…"
|
||||
placeholder="Search title, filename…"
|
||||
style={{ padding: "6px 10px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4, width: 220 }}
|
||||
/>
|
||||
|
||||
@@ -584,9 +490,13 @@ function FilterBar({
|
||||
{params.order === "asc" ? "↑ Asc" : "↓ Desc"}
|
||||
</button>
|
||||
|
||||
{(params.search || params.status || params.document_type) && (
|
||||
{anyFilterActive && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(""); onChange({ search: undefined, status: undefined, document_type: undefined, page: 1 }); }}
|
||||
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
|
||||
@@ -645,18 +555,34 @@ export default function DocumentsPage() {
|
||||
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),
|
||||
@@ -671,6 +597,10 @@ export default function DocumentsPage() {
|
||||
queryFn: listCategories,
|
||||
});
|
||||
|
||||
const activeCategory = categoryIdFromUrl
|
||||
? categories.find((c) => c.id === categoryIdFromUrl)
|
||||
: undefined;
|
||||
|
||||
const uploadMut = useMutation({
|
||||
mutationFn: uploadDocument,
|
||||
onSuccess: () => {
|
||||
@@ -760,14 +690,19 @@ export default function DocumentsPage() {
|
||||
</details>
|
||||
|
||||
{/* Filter bar */}
|
||||
<FilterBar params={params} onChange={updateParams} />
|
||||
<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
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user