diff --git a/changelog/2026-04-17_switch-penpot-to-figma.md b/changelog/2026-04-17_switch-penpot-to-figma.md index a94887e..e74e4f2 100644 --- a/changelog/2026-04-17_switch-penpot-to-figma.md +++ b/changelog/2026-04-17_switch-penpot-to-figma.md @@ -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 - `features/ai-service/STATUS.md` — Documented system prompts architecture - `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 `` 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 `` vs `
`; 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=` +- `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 diff --git a/features/doc-service/STATUS.md b/features/doc-service/STATUS.md index d23c0ac..167a53a 100644 --- a/features/doc-service/STATUS.md +++ b/features/doc-service/STATUS.md @@ -23,7 +23,7 @@ Database: shared PostgreSQL instance, isolated via `alembic_version_doc_service` | Method | Path | Description | |--------|------|-------------| | `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}/status` | Lightweight status poll | | `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}/title` | Update editable title | | `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 | | `DELETE` | `/documents/{id}/categories/{cat_id}` | Remove category from document | @@ -49,6 +51,7 @@ Query params: | `status` | — | filter by status string | | `document_type` | — | filter by 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 }` diff --git a/features/doc-service/app/routers/categories.py b/features/doc-service/app/routers/categories.py index 1392cda..53999d4 100644 --- a/features/doc-service/app/routers/categories.py +++ b/features/doc-service/app/routers/categories.py @@ -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.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.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.services.ai_client import classify_document 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]) async def list_categories( @@ -26,16 +92,40 @@ async def list_categories( @router.post("", response_model=CategoryOut, status_code=201) async def create_category( body: CategoryCreate, + background_tasks: BackgroundTasks, user_id: str = Depends(get_user_id), db: AsyncSession = Depends(get_db), ) -> DocumentCategory: name = body.name.strip() if not name: raise HTTPException(status_code=422, detail="Category name cannot be empty") + cat = DocumentCategory(user_id=user_id, name=name[:128]) db.add(cat) await db.commit() 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 diff --git a/features/doc-service/app/routers/documents.py b/features/doc-service/app/routers/documents.py index 924ca9c..85d5972 100644 --- a/features/doc-service/app/routers/documents.py +++ b/features/doc-service/app/routers/documents.py @@ -175,6 +175,7 @@ async def list_documents( status: str | None = Query(default=None), document_type: 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), db: AsyncSession = Depends(get_db), ) -> DocumentPage: @@ -197,6 +198,11 @@ async def list_documents( 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( select(func.count(Document.id)).where(*conditions) diff --git a/frontend/STATUS.md b/frontend/STATUS.md index 86fefb7..ab8a7a4 100644 --- a/frontend/STATUS.md +++ b/frontend/STATUS.md @@ -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=` +- 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 | diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 086e68c..0d08043 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -113,6 +113,7 @@ export interface DocumentListParams { status?: string; document_type?: string; search?: string; + category_id?: string; } export interface DocumentStatusOut { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index cff72c9..7f0df97 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 (