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:
curo1305
2026-04-17 16:57:35 +02:00
parent bc7a74062d
commit 7d0edbd5e7
8 changed files with 384 additions and 193 deletions
+9 -2
View File
@@ -39,6 +39,14 @@ Cards for each installed app:
- **Documents** — link to `/apps/documents`; admin gear icon → `/apps/documents/settings/admin`
- **AI Service** — infrastructure card; admin gear icon → `/apps/ai/settings/admin`; no Open button (no user-facing UI)
### Sidebar navigation
`Apps` is an expandable accordion in the sidebar:
- **Documents** sub-item (expandable) — lists all user categories beneath it; clicking a category navigates to `/apps/documents?category_id=<id>`
- AI Service is not listed (no openable UI)
- Sections auto-open when navigating to their route
- In collapsed (icons-only) mode, clicking the Apps icon navigates to `/apps`
### Documents page (`/apps/documents`)
**Upload:** PDF file input, 202 response, error display.
@@ -63,7 +71,6 @@ Cards for each installed app:
- Delete button (confirm dialog)
**Document row (expanded):**
- **Tag editor** — read mode shows chips + Edit button; edit mode has removable chips + input (Enter/comma to add) + Save/Cancel
- **Extracted data table** — all AI-extracted JSON fields (excludes `tags`, `suggested_categories`)
- **Error message** — shown if status=failed
- **Categories** — assigned chips with remove; dropdown to assign existing; AI-suggested chips with Accept / Create & Assign / Dismiss
@@ -98,7 +105,7 @@ Key functions:
| Function | Description |
|----------|-------------|
| `listDocuments(params)` | `GET /documents` — returns `DocumentPage` |
| `listDocuments(params)` | `GET /documents` — returns `DocumentPage`; supports `category_id` filter |
| `uploadDocument(file)` | `POST /documents/upload` |
| `deleteDocument(id)` | `DELETE /documents/{id}` |
| `downloadDocument(id, filename)` | Blob URL download |
+1
View File
@@ -113,6 +113,7 @@ export interface DocumentListParams {
status?: string;
document_type?: string;
search?: string;
category_id?: string;
}
export interface DocumentStatusOut {
+178 -57
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import { useState, useEffect } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Home,
@@ -8,72 +8,197 @@ import {
ShieldCheck,
ChevronRight,
ChevronLeft,
ChevronDown,
LogOut,
UserCircle,
FileText,
Folder,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import ThemeToggle from "@/components/ThemeToggle";
import { useAuth } from "@/hooks/useAuth";
import { getMe } from "@/api/client";
import { getMe, listCategories } from "@/api/client";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ to: "/", label: "Home", icon: Home, exact: true },
{ to: "/apps", label: "Apps", icon: Grid2X2, exact: false },
{ to: "/settings", label: "Settings", icon: Settings, exact: false },
];
export default function Sidebar() {
const [expanded, setExpanded] = useState(true);
const [sidebarExpanded, setSidebarExpanded] = useState(true);
const { logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
const isAppsRoute = location.pathname.startsWith("/apps");
const isDocsRoute = location.pathname.startsWith("/apps/documents");
const [appsOpen, setAppsOpen] = useState(isAppsRoute);
const [docsOpen, setDocsOpen] = useState(isDocsRoute);
// Auto-open sections when navigating to their routes
useEffect(() => {
if (isAppsRoute) setAppsOpen(true);
}, [isAppsRoute]);
useEffect(() => {
if (isDocsRoute) setDocsOpen(true);
}, [isDocsRoute]);
const { data: categories = [] } = useQuery({
queryKey: ["categories"],
queryFn: listCategories,
enabled: appsOpen && docsOpen && !!user,
});
const navItemClass = (isActive: boolean) =>
cn(
"flex items-center rounded-lg transition-colors",
sidebarExpanded ? "px-3 py-2 gap-3" : "justify-center py-3",
isActive
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
const subItemClass = (isActive: boolean) =>
cn(
"flex items-center rounded-lg transition-colors text-sm",
"pl-8 pr-3 py-1.5 gap-2",
isActive
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
const subSubItemClass = (isActive: boolean) =>
cn(
"flex items-center rounded-lg transition-colors text-sm",
"pl-12 pr-3 py-1 gap-2",
isActive
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
return (
<aside
className={cn(
"flex flex-col h-screen bg-surface border-r border-border transition-all duration-200 shrink-0",
expanded ? "w-56" : "w-16"
sidebarExpanded ? "w-56" : "w-16"
)}
>
{/* Nav items */}
<nav className="flex-1 py-4 px-2 space-y-1 overflow-hidden">
{NAV_ITEMS.map(({ to, label, icon: Icon, exact }) => (
<NavLink
key={to}
to={to}
end={exact}
className={({ isActive }) =>
cn(
"flex items-center rounded-lg transition-colors",
expanded ? "px-3 py-2 gap-3" : "justify-center py-3",
isActive
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
)
}
>
<Icon className="h-5 w-5 shrink-0" />
{expanded && (
<span className="text-sm font-medium whitespace-nowrap">{label}</span>
)}
</NavLink>
))}
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto overflow-x-hidden">
{/* Home */}
<NavLink
to="/"
end
className={({ isActive }) => navItemClass(isActive)}
>
<Home className="h-5 w-5 shrink-0" />
{sidebarExpanded && (
<span className="text-sm font-medium whitespace-nowrap">Home</span>
)}
</NavLink>
{/* Apps — expandable */}
<div>
<button
onClick={() => {
if (!sidebarExpanded) {
navigate("/apps");
return;
}
setAppsOpen((o) => !o);
}}
className={cn(
"w-full flex items-center rounded-lg transition-colors",
sidebarExpanded ? "px-3 py-2 gap-3" : "justify-center py-3",
isAppsRoute
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
)}
>
<Grid2X2 className="h-5 w-5 shrink-0" />
{sidebarExpanded && (
<>
<span className="text-sm font-medium whitespace-nowrap flex-1 text-left">
Apps
</span>
{appsOpen ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
</>
)}
</button>
{/* Apps sub-items — only when sidebar is expanded and appsOpen */}
{sidebarExpanded && appsOpen && (
<div className="mt-0.5 space-y-0.5">
{/* Documents service */}
<div>
<button
onClick={() => setDocsOpen((o) => !o)}
className={cn(
"w-full flex items-center rounded-lg transition-colors text-sm",
"pl-8 pr-3 py-1.5 gap-2",
isDocsRoute
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
)}
>
<FileText className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left whitespace-nowrap">Documents</span>
{docsOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
</button>
{/* Documents open link + categories */}
{docsOpen && (
<div className="mt-0.5 space-y-0.5">
<NavLink
to="/apps/documents"
end
className={({ isActive }) => subSubItemClass(isActive)}
>
<span className="whitespace-nowrap">All documents</span>
</NavLink>
{categories.map((cat) => (
<NavLink
key={cat.id}
to={`/apps/documents?category_id=${cat.id}`}
className={({ isActive }) => subSubItemClass(isActive)}
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="whitespace-nowrap truncate">{cat.name}</span>
</NavLink>
))}
</div>
)}
</div>
</div>
)}
</div>
{/* Settings */}
<NavLink
to="/settings"
className={({ isActive }) => navItemClass(isActive)}
>
<Settings className="h-5 w-5 shrink-0" />
{sidebarExpanded && (
<span className="text-sm font-medium whitespace-nowrap">Settings</span>
)}
</NavLink>
{/* Admin */}
{user?.is_admin && (
<NavLink
to="/admin"
className={({ isActive }) =>
cn(
"flex items-center rounded-lg transition-colors",
expanded ? "px-3 py-2 gap-3" : "justify-center py-3",
isActive
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
)
}
className={({ isActive }) => navItemClass(isActive)}
>
<ShieldCheck className="h-5 w-5 shrink-0" />
{expanded && (
{sidebarExpanded && (
<span className="text-sm font-medium whitespace-nowrap">Admin</span>
)}
</NavLink>
@@ -82,46 +207,42 @@ export default function Sidebar() {
{/* Bottom section */}
<div className="py-4 px-2 space-y-1 border-t border-border">
{/* User avatar row */}
<div
className={cn(
"flex items-center rounded-lg py-2 text-muted",
expanded ? "px-3 gap-3" : "justify-center"
sidebarExpanded ? "px-3 gap-3" : "justify-center"
)}
>
<UserCircle className="h-5 w-5 shrink-0" />
{expanded && user && (
{sidebarExpanded && user && (
<span className="text-sm font-medium truncate">{user.email}</span>
)}
</div>
{/* Theme toggle */}
<div className={cn("flex", expanded ? "px-1" : "justify-center")}>
<div className={cn("flex", sidebarExpanded ? "px-1" : "justify-center")}>
<ThemeToggle />
</div>
{/* Logout */}
<Button
variant="ghost"
className={cn(
"w-full text-muted hover:text-foreground",
expanded ? "justify-start px-3 gap-3" : "justify-center px-0"
sidebarExpanded ? "justify-start px-3 gap-3" : "justify-center px-0"
)}
onClick={logout}
>
<LogOut className="h-5 w-5 shrink-0" />
{expanded && <span className="text-sm font-medium">Logout</span>}
{sidebarExpanded && <span className="text-sm font-medium">Logout</span>}
</Button>
{/* Collapse toggle */}
<Button
variant="ghost"
size="icon"
className={cn("w-full text-muted hover:text-foreground", !expanded && "px-0")}
onClick={() => setExpanded((e) => !e)}
aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"}
className={cn("w-full text-muted hover:text-foreground", !sidebarExpanded && "px-0")}
onClick={() => setSidebarExpanded((e) => !e)}
aria-label={sidebarExpanded ? "Collapse sidebar" : "Expand sidebar"}
>
{expanded ? (
{sidebarExpanded ? (
<ChevronLeft className="h-5 w-5" />
) : (
<ChevronRight className="h-5 w-5" />
+65 -130
View File
@@ -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>