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
@@ -78,3 +78,31 @@
- `frontend/src/pages/AIAdminSettingsPage.tsx` — Refactored to tab view (General | System Prompts); System Prompts tab shows per-service editable textarea cards - `frontend/src/pages/AIAdminSettingsPage.tsx` — Refactored to tab view (General | System Prompts); System Prompts tab shows per-service editable textarea cards
- `features/ai-service/STATUS.md` — Documented system prompts architecture - `features/ai-service/STATUS.md` — Documented system prompts architecture
- `features/doc-service/STATUS.md` — Documented runtime prompt loading - `features/doc-service/STATUS.md` — Documented runtime prompt loading
---
# 2026-04-17 — Apps page: card surface colour + whole-card click
**Timestamp:** 2026-04-17T00:00:00
**Summary:** Cards on the Apps page now render with the `--color-surface` token (distinct from the page background), and clickable cards (status = available + path set) are wrapped in a `<Link>` so the entire frame navigates to the app. The Settings link is unchanged and stops click propagation.
**Files Modified:**
- `frontend/src/pages/AppsPage.tsx` — Added `cardStyle`/`clickableCardStyle` objects using CSS custom properties; conditionally wraps card in `<Link>` vs `<div>`; removed standalone "Open" button; settings link gains `e.stopPropagation()`
---
# 2026-04-17 — Sidebar app sub-navigation, category filtering, and re-analysis on category creation
**Timestamp:** 2026-04-17T00:00:00
**Summary:** Added expandable Apps section to the sidebar with Documents → categories sub-navigation. Clicking a category filters the documents view. Removed tag UI from the document list (deferred). When a new category is created, similar existing categories are detected and affected documents are re-analysed in the background so the new category surfaces as a pending AI suggestion.
**Files Modified:**
- `frontend/src/components/Sidebar.tsx` — replaced flat Apps nav item with collapsible accordion; Documents sub-item expands to list all user categories; category links navigate to `/apps/documents?category_id=<id>`
- `frontend/src/pages/DocumentsPage.tsx` — removed `TagEditor` component; added `useSearchParams` for `category_id` URL param; category filter chip shown in FilterBar with dismiss button; "Clear filters" now also clears category filter
- `frontend/src/api/client.ts` — added `category_id` field to `DocumentListParams`
- `features/doc-service/app/routers/documents.py` — added `category_id` query param to `GET /documents`; filters via subquery on `category_assignments`
- `features/doc-service/app/routers/categories.py``POST /documents/categories` now finds similar categories by name (word overlap + SequenceMatcher) and triggers a background task to re-run AI extraction on affected documents, merging new `suggested_categories` into their `extracted_data`
- `features/doc-service/STATUS.md` — updated endpoints table and filter params table
- `frontend/STATUS.md` — updated sidebar and documents page sections
+5 -2
View File
@@ -23,7 +23,7 @@ Database: shared PostgreSQL instance, isolated via `alembic_version_doc_service`
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| `POST` | `/documents/upload` | Upload PDF; returns 202 with initial doc row | | `POST` | `/documents/upload` | Upload PDF; returns 202 with initial doc row |
| `GET` | `/documents` | Paginated list with filters and sort | | `GET` | `/documents` | Paginated list with filters, sort, and optional `category_id` filter |
| `GET` | `/documents/{id}` | Single document | | `GET` | `/documents/{id}` | Single document |
| `GET` | `/documents/{id}/status` | Lightweight status poll | | `GET` | `/documents/{id}/status` | Lightweight status poll |
| `GET` | `/documents/{id}/download` | Stream file bytes | | `GET` | `/documents/{id}/download` | Stream file bytes |
@@ -32,7 +32,9 @@ Database: shared PostgreSQL instance, isolated via `alembic_version_doc_service`
| `PATCH` | `/documents/{id}/tags` | Replace tag list (dedup, preserve order) | | `PATCH` | `/documents/{id}/tags` | Replace tag list (dedup, preserve order) |
| `PATCH` | `/documents/{id}/title` | Update editable title | | `PATCH` | `/documents/{id}/title` | Update editable title |
| `GET` | `/documents/categories` | List all categories for the user | | `GET` | `/documents/categories` | List all categories for the user |
| `POST` | `/documents/categories` | Create a category | | `POST` | `/documents/categories` | Create a category; triggers re-analysis of documents in similar categories |
| `PATCH` | `/documents/categories/{id}` | Rename a category |
| `DELETE` | `/documents/categories/{id}` | Delete a category |
| `POST` | `/documents/{id}/categories/{cat_id}` | Assign category to document | | `POST` | `/documents/{id}/categories/{cat_id}` | Assign category to document |
| `DELETE` | `/documents/{id}/categories/{cat_id}` | Remove category from document | | `DELETE` | `/documents/{id}/categories/{cat_id}` | Remove category from document |
@@ -49,6 +51,7 @@ Query params:
| `status` | — | filter by status string | | `status` | — | filter by status string |
| `document_type` | — | filter by document type | | `document_type` | — | filter by document type |
| `search` | — | case-insensitive ILIKE on `title`, `filename`, `tags`, `document_type` | | `search` | — | case-insensitive ILIKE on `title`, `filename`, `tags`, `document_type` |
| `category_id` | — | filter to documents assigned to this category UUID |
Response: `{ items: [...], total: N, page: N, pages: N }` Response: `{ items: [...], total: N, page: N, pages: N }`
+92 -2
View File
@@ -1,14 +1,80 @@
from fastapi import APIRouter, Depends, HTTPException import difflib
import json
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import AsyncSessionLocal, get_db
from app.deps import get_user_id from app.deps import get_user_id
from app.models.category import DocumentCategory from app.models.category import DocumentCategory
from app.models.category_assignment import CategoryAssignment
from app.models.document import Document
from app.schemas.category import CategoryCreate, CategoryOut, CategoryUpdate from app.schemas.category import CategoryCreate, CategoryOut, CategoryUpdate
from app.services.ai_client import classify_document
router = APIRouter() router = APIRouter()
_SIMILARITY_THRESHOLD = 0.4
def _name_similarity(a: str, b: str) -> float:
"""Return similarity score (01) between two category names."""
a_low = a.lower()
b_low = b.lower()
# Word overlap is a strong signal
a_words = set(a_low.split())
b_words = set(b_low.split())
if a_words & b_words:
return 0.9
# Fallback: character sequence ratio
return difflib.SequenceMatcher(None, a_low, b_low).ratio()
async def _reanalyze_documents_for_new_category(
new_cat_name: str,
user_id: str,
similar_cat_ids: list[str],
) -> None:
"""
Background task: re-run AI extraction on documents that belong to similar
categories, then merge any new suggested_categories into their extracted_data.
The suggestions surface as pending chips in the UI — the user still confirms.
"""
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Document)
.join(CategoryAssignment, CategoryAssignment.document_id == Document.id)
.where(CategoryAssignment.category_id.in_(similar_cat_ids))
.where(Document.user_id == user_id)
.where(Document.status == "done")
)
docs = list(result.scalars().unique())
for doc in docs:
if not doc.raw_text:
continue
try:
ai_result = await classify_document(doc.raw_text)
new_suggestions: list[str] = ai_result.get("suggested_categories", [])
existing_data: dict = {}
if doc.extracted_data:
try:
existing_data = json.loads(doc.extracted_data)
except Exception:
pass
existing_sugg: list[str] = existing_data.get("suggested_categories", [])
# Merge: preserve existing, append new ones not already present
merged = list(dict.fromkeys(existing_sugg + new_suggestions))
existing_data["suggested_categories"] = merged
doc.extracted_data = json.dumps(existing_data)
await db.commit()
except Exception:
# Don't let a single document failure abort the rest
pass
@router.get("", response_model=list[CategoryOut]) @router.get("", response_model=list[CategoryOut])
async def list_categories( async def list_categories(
@@ -26,16 +92,40 @@ async def list_categories(
@router.post("", response_model=CategoryOut, status_code=201) @router.post("", response_model=CategoryOut, status_code=201)
async def create_category( async def create_category(
body: CategoryCreate, body: CategoryCreate,
background_tasks: BackgroundTasks,
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> DocumentCategory: ) -> DocumentCategory:
name = body.name.strip() name = body.name.strip()
if not name: if not name:
raise HTTPException(status_code=422, detail="Category name cannot be empty") raise HTTPException(status_code=422, detail="Category name cannot be empty")
cat = DocumentCategory(user_id=user_id, name=name[:128]) cat = DocumentCategory(user_id=user_id, name=name[:128])
db.add(cat) db.add(cat)
await db.commit() await db.commit()
await db.refresh(cat) await db.refresh(cat)
# Find existing categories with similar names
result = await db.execute(
select(DocumentCategory)
.where(DocumentCategory.user_id == user_id)
.where(DocumentCategory.id != cat.id)
)
all_cats = result.scalars().all()
similar_ids = [
c.id
for c in all_cats
if _name_similarity(name, c.name) >= _SIMILARITY_THRESHOLD
]
if similar_ids:
background_tasks.add_task(
_reanalyze_documents_for_new_category,
name,
user_id,
similar_ids,
)
return cat return cat
@@ -175,6 +175,7 @@ async def list_documents(
status: str | None = Query(default=None), status: str | None = Query(default=None),
document_type: str | None = Query(default=None), document_type: str | None = Query(default=None),
search: str | None = Query(default=None), search: str | None = Query(default=None),
category_id: str | None = Query(default=None),
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> DocumentPage: ) -> DocumentPage:
@@ -197,6 +198,11 @@ async def list_documents(
Document.document_type.ilike(like), Document.document_type.ilike(like),
) )
) )
if category_id:
subq = select(CategoryAssignment.document_id).where(
CategoryAssignment.category_id == category_id
)
conditions.append(Document.id.in_(subq))
count_result = await db.execute( count_result = await db.execute(
select(func.count(Document.id)).where(*conditions) select(func.count(Document.id)).where(*conditions)
+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` - **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) - **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`) ### Documents page (`/apps/documents`)
**Upload:** PDF file input, 202 response, error display. **Upload:** PDF file input, 202 response, error display.
@@ -63,7 +71,6 @@ Cards for each installed app:
- Delete button (confirm dialog) - Delete button (confirm dialog)
**Document row (expanded):** **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`) - **Extracted data table** — all AI-extracted JSON fields (excludes `tags`, `suggested_categories`)
- **Error message** — shown if status=failed - **Error message** — shown if status=failed
- **Categories** — assigned chips with remove; dropdown to assign existing; AI-suggested chips with Accept / Create & Assign / Dismiss - **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 | | Function | Description |
|----------|-------------| |----------|-------------|
| `listDocuments(params)` | `GET /documents` — returns `DocumentPage` | | `listDocuments(params)` | `GET /documents` — returns `DocumentPage`; supports `category_id` filter |
| `uploadDocument(file)` | `POST /documents/upload` | | `uploadDocument(file)` | `POST /documents/upload` |
| `deleteDocument(id)` | `DELETE /documents/{id}` | | `deleteDocument(id)` | `DELETE /documents/{id}` |
| `downloadDocument(id, filename)` | Blob URL download | | `downloadDocument(id, filename)` | Blob URL download |
+1
View File
@@ -113,6 +113,7 @@ export interface DocumentListParams {
status?: string; status?: string;
document_type?: string; document_type?: string;
search?: string; search?: string;
category_id?: string;
} }
export interface DocumentStatusOut { export interface DocumentStatusOut {
+174 -53
View File
@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { NavLink } from "react-router-dom"; import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Home, Home,
@@ -8,72 +8,197 @@ import {
ShieldCheck, ShieldCheck,
ChevronRight, ChevronRight,
ChevronLeft, ChevronLeft,
ChevronDown,
LogOut, LogOut,
UserCircle, UserCircle,
FileText,
Folder,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import ThemeToggle from "@/components/ThemeToggle"; import ThemeToggle from "@/components/ThemeToggle";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { getMe } from "@/api/client"; import { getMe, listCategories } from "@/api/client";
import { cn } from "@/lib/utils"; 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() { export default function Sidebar() {
const [expanded, setExpanded] = useState(true); const [sidebarExpanded, setSidebarExpanded] = useState(true);
const { logout } = useAuth(); const { logout } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe }); 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 ( return (
<aside <aside
className={cn( className={cn(
"flex flex-col h-screen bg-surface border-r border-border transition-all duration-200 shrink-0", "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-y-auto overflow-x-hidden">
<nav className="flex-1 py-4 px-2 space-y-1 overflow-hidden"> {/* Home */}
{NAV_ITEMS.map(({ to, label, icon: Icon, exact }) => (
<NavLink <NavLink
key={to} to="/"
to={to} end
end={exact} className={({ isActive }) => navItemClass(isActive)}
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" /> <Home className="h-5 w-5 shrink-0" />
{expanded && ( {sidebarExpanded && (
<span className="text-sm font-medium whitespace-nowrap">{label}</span> <span className="text-sm font-medium whitespace-nowrap">Home</span>
)} )}
</NavLink> </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 && ( {user?.is_admin && (
<NavLink <NavLink
to="/admin" to="/admin"
className={({ isActive }) => className={({ isActive }) => navItemClass(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"
)
}
> >
<ShieldCheck className="h-5 w-5 shrink-0" /> <ShieldCheck className="h-5 w-5 shrink-0" />
{expanded && ( {sidebarExpanded && (
<span className="text-sm font-medium whitespace-nowrap">Admin</span> <span className="text-sm font-medium whitespace-nowrap">Admin</span>
)} )}
</NavLink> </NavLink>
@@ -82,46 +207,42 @@ export default function Sidebar() {
{/* Bottom section */} {/* Bottom section */}
<div className="py-4 px-2 space-y-1 border-t border-border"> <div className="py-4 px-2 space-y-1 border-t border-border">
{/* User avatar row */}
<div <div
className={cn( className={cn(
"flex items-center rounded-lg py-2 text-muted", "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" /> <UserCircle className="h-5 w-5 shrink-0" />
{expanded && user && ( {sidebarExpanded && user && (
<span className="text-sm font-medium truncate">{user.email}</span> <span className="text-sm font-medium truncate">{user.email}</span>
)} )}
</div> </div>
{/* Theme toggle */} <div className={cn("flex", sidebarExpanded ? "px-1" : "justify-center")}>
<div className={cn("flex", expanded ? "px-1" : "justify-center")}>
<ThemeToggle /> <ThemeToggle />
</div> </div>
{/* Logout */}
<Button <Button
variant="ghost" variant="ghost"
className={cn( className={cn(
"w-full text-muted hover:text-foreground", "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} onClick={logout}
> >
<LogOut className="h-5 w-5 shrink-0" /> <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> </Button>
{/* Collapse toggle */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn("w-full text-muted hover:text-foreground", !expanded && "px-0")} className={cn("w-full text-muted hover:text-foreground", !sidebarExpanded && "px-0")}
onClick={() => setExpanded((e) => !e)} onClick={() => setSidebarExpanded((e) => !e)}
aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"} aria-label={sidebarExpanded ? "Collapse sidebar" : "Expand sidebar"}
> >
{expanded ? ( {sidebarExpanded ? (
<ChevronLeft className="h-5 w-5" /> <ChevronLeft className="h-5 w-5" />
) : ( ) : (
<ChevronRight 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 { useRef, useState, useEffect, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
listDocuments, listDocuments,
@@ -11,7 +12,6 @@ import {
createCategory, createCategory,
assignCategory, assignCategory,
removeCategory, removeCategory,
updateDocumentTags,
updateDocumentTitle, updateDocumentTitle,
type DocumentOut, type DocumentOut,
type CategoryOut, type CategoryOut,
@@ -88,8 +88,6 @@ function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipP
); );
} }
// ── Document row ────────────────────────────────────────────────────────────
// ── Inline title editor ───────────────────────────────────────────────────── // ── Inline title editor ─────────────────────────────────────────────────────
function InlineTitleEditor({ 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 ──────────────────────────────────────────────────────────── // ── Document row ────────────────────────────────────────────────────────────
function DocumentRow({ function DocumentRow({
@@ -273,28 +169,18 @@ function DocumentRow({
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const qc = useQueryClient(); const qc = useQueryClient();
// Parse extracted_data once
let extractedData: Record<string, unknown> | null = null; let extractedData: Record<string, unknown> | null = null;
if (doc.extracted_data) { if (doc.extracted_data) {
try { extractedData = JSON.parse(doc.extracted_data); } catch { /* ignore */ } try { extractedData = JSON.parse(doc.extracted_data); } catch { /* ignore */ }
} }
const tags: string[] = []; // Suggested categories from AI — dismissed ones tracked locally
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
const allSuggestions: string[] = Array.isArray(extractedData?.suggested_categories) const allSuggestions: string[] = Array.isArray(extractedData?.suggested_categories)
? (extractedData!.suggested_categories as string[]) ? (extractedData!.suggested_categories as string[])
: []; : [];
const assignedNames = new Set(doc.categories.map((c) => c.name)); const assignedNames = new Set(doc.categories.map((c) => c.name));
const [dismissed, setDismissed] = useState<Set<string>>(new Set()); 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( const pendingSuggestions = allSuggestions.filter(
(s) => !assignedNames.has(s) && !dismissed.has(s) (s) => !assignedNames.has(s) && !dismissed.has(s)
); );
@@ -405,13 +291,6 @@ function DocumentRow({
{expanded && ( {expanded && (
<div style={{ padding: "0 16px 16px", borderTop: "1px solid #eee" }}> <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) */} {/* Extracted fields (excluding internal-only keys) */}
{extractedData && ( {extractedData && (
<div style={{ marginTop: 10 }}> <div style={{ marginTop: 10 }}>
@@ -473,7 +352,7 @@ function DocumentRow({
)} )}
</div> </div>
{/* AI-suggested categories */} {/* AI-suggested categories — user must confirm each one */}
{pendingSuggestions.length > 0 && ( {pendingSuggestions.length > 0 && (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<strong style={{ fontSize: 13 }}>Suggested by AI:</strong> <strong style={{ fontSize: 13 }}>Suggested by AI:</strong>
@@ -521,26 +400,53 @@ const TYPE_OPTIONS = ["invoice", "bill", "receipt", "order", "expense", "revenue
function FilterBar({ function FilterBar({
params, params,
activeCategory,
onChange, onChange,
onClearCategory,
}: { }: {
params: DocumentListParams; params: DocumentListParams;
activeCategory: CategoryOut | undefined;
onChange: (p: Partial<DocumentListParams>) => void; onChange: (p: Partial<DocumentListParams>) => void;
onClearCategory: () => void;
}) { }) {
const [searchInput, setSearchInput] = useState(params.search ?? ""); const [searchInput, setSearchInput] = useState(params.search ?? "");
// Debounce search: commit after 400 ms of no typing
useEffect(() => { useEffect(() => {
const id = setTimeout(() => onChange({ search: searchInput || undefined, page: 1 }), 400); const id = setTimeout(() => onChange({ search: searchInput || undefined, page: 1 }), 400);
return () => clearTimeout(id); return () => clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]); }, [searchInput]);
const anyFilterActive = !!(params.search || params.status || params.document_type || params.category_id);
return ( return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 16, alignItems: "center" }}> <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 <input
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} 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 }} 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"} {params.order === "asc" ? "↑ Asc" : "↓ Desc"}
</button> </button>
{(params.search || params.status || params.document_type) && ( {anyFilterActive && (
<button <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" }} style={{ padding: "6px 10px", fontSize: 12, border: "1px solid #ddd", borderRadius: 4, cursor: "pointer", color: "#666", background: "#fafafa" }}
> >
Clear filters Clear filters
@@ -645,18 +555,34 @@ export default function DocumentsPage() {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [newCatName, setNewCatName] = useState(""); const [newCatName, setNewCatName] = useState("");
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const categoryIdFromUrl = searchParams.get("category_id") ?? undefined;
const [params, setParams] = useState<DocumentListParams>({ const [params, setParams] = useState<DocumentListParams>({
page: 1, page: 1,
per_page: 20, per_page: 20,
sort: "created_at", sort: "created_at",
order: "desc", 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>) => { const updateParams = useCallback((patch: Partial<DocumentListParams>) => {
setParams((prev) => ({ ...prev, ...patch })); setParams((prev) => ({ ...prev, ...patch }));
}, []); }, []);
const clearCategoryFilter = useCallback(() => {
setSearchParams((sp) => {
sp.delete("category_id");
return sp;
});
}, [setSearchParams]);
const { data: docPage, isLoading } = useQuery({ const { data: docPage, isLoading } = useQuery({
queryKey: ["documents", params], queryKey: ["documents", params],
queryFn: () => listDocuments(params), queryFn: () => listDocuments(params),
@@ -671,6 +597,10 @@ export default function DocumentsPage() {
queryFn: listCategories, queryFn: listCategories,
}); });
const activeCategory = categoryIdFromUrl
? categories.find((c) => c.id === categoryIdFromUrl)
: undefined;
const uploadMut = useMutation({ const uploadMut = useMutation({
mutationFn: uploadDocument, mutationFn: uploadDocument,
onSuccess: () => { onSuccess: () => {
@@ -760,14 +690,19 @@ export default function DocumentsPage() {
</details> </details>
{/* Filter bar */} {/* Filter bar */}
<FilterBar params={params} onChange={updateParams} /> <FilterBar
params={params}
activeCategory={activeCategory}
onChange={updateParams}
onClearCategory={clearCategoryFilter}
/>
{/* Document list */} {/* Document list */}
{isLoading ? ( {isLoading ? (
<p>Loading</p> <p>Loading</p>
) : documents.length === 0 ? ( ) : documents.length === 0 ? (
<p style={{ color: "#666" }}> <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 yet. Upload a PDF to get started."
: "No documents match the current filters."} : "No documents match the current filters."}
</p> </p>