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:
@@ -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
|
||||||
|
|||||||
@@ -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 }`
|
||||||
|
|
||||||
|
|||||||
@@ -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 (0–1) 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
@@ -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 |
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user