Redesign doc service UX for scale + add group-based document sharing

- Three-column layout: Sidebar + SourcePanel (views + searchable category tree) + main
- DocumentSlideOver (480px right panel): inline editing, type picker, AI suggestion confirm/reject,
  categories combobox, tags editor, sharing section, raw text, re-analyse/delete actions
- ManageCategoriesDialog: inline rename, delete with confirm, search filter
- DocumentsPage rewrite: filter chip system, multi-file upload queue, drag-and-drop overlay,
  bulk actions bar (share/delete), smart TanStack Query polling, URL-driven view state
- Sidebar simplified: per-category NavLinks removed; Documents = single NavLink under Apps
- Backend: document_shares table (migration 0004), share CRUD endpoints, shared-with-me view,
  N+1-safe share_count via GROUP BY, recipient download access, X-User-Groups header enforcement
- Gateway proxy: injects X-User-Groups header into all document + category proxy requests
- Backend users: GET /api/users/me/groups endpoint for share picker combobox
- CLAUDE.md, STATUS.md files, and changelog updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 12:46:43 +02:00
parent 08e7caac4c
commit 94901fc30f
23 changed files with 2603 additions and 900 deletions
+37 -10
View File
@@ -84,7 +84,7 @@ docker compose up --build -d
│ ├── app/
│ │ ├── main.py ← App factory, router registration, lifespan (health loop)
│ │ ├── database.py ← AsyncEngine, AsyncSessionLocal, Base
│ │ ├── deps.py ← get_current_user, get_current_admin, get_service_admin(id), check_plugin_access
│ │ ├── deps.py ← get_current_user, get_current_admin, get_service_admin(id), check_plugin_access (also get_user_groups in doc-service)
│ │ ├── core/
│ │ │ ├── config.py ← All settings via pydantic-settings (reads .env)
│ │ │ ├── security.py ← JWT sign/verify (RS256), bcrypt hash/verify
@@ -101,7 +101,7 @@ docker compose up --build -d
│ │ │ └── group.py ← GroupCreate/Update/Out/DetailOut, GroupMemberOut
│ │ ├── routers/
│ │ │ ├── auth.py ← POST /register, POST /login
│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences, PATCH /me/color-mode
│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences, PATCH /me/color-mode, GET /me/groups
│ │ │ ├── profile.py ← GET+PUT /me (profile)
│ │ │ ├── admin.py ← User admin CRUD (admin-only)
│ │ │ ├── groups.py ← Group CRUD + member management (admin-only)
@@ -139,16 +139,18 @@ docker compose up --build -d
│ ├── app/
│ │ ├── main.py ← FastAPI, lifespan (file watcher start/stop)
│ │ ├── database.py ← Same PostgreSQL instance as backend
│ │ ├── deps.py ← get_user_id (reads x-user-id header)
│ │ ├── deps.py ← get_user_id (x-user-id), get_user_groups (x-user-groups)
│ │ ├── models/
│ │ │ ├── document.py ← Document model (see Database Models)
│ │ │ ├── category.py ← DocumentCategory model
│ │ │ ── category_assignment.py ← CategoryAssignment (composite PK)
│ │ │ ── category_assignment.py ← CategoryAssignment (composite PK)
│ │ │ └── document_share.py ← DocumentShare model (group-based sharing)
│ │ ├── schemas/
│ │ │ ├── document.py ← DocumentOut, DocumentPage, DocumentStatusOut, etc.
│ │ │ ── category.py ← CategoryOut, CategoryCreate, CategoryUpdate
│ │ │ ── category.py ← CategoryOut, CategoryCreate, CategoryUpdate
│ │ │ └── share.py ← DocumentShareOut, DocumentShareCreate, SharedDocumentOut
│ │ ├── routers/
│ │ │ ├── documents.py ← Full document CRUD + file serving + reprocess + suggestion endpoints
│ │ │ ├── documents.py ← Full CRUD + file serving + reprocess + suggestions + sharing
│ │ │ ├── categories.py ← Category CRUD (includes watch-owned categories)
│ │ │ └── plugin.py ← GET /plugin/manifest, GET+PATCH /plugin/settings
│ │ └── services/
@@ -157,7 +159,8 @@ docker compose up --build -d
│ │ ├── config_reader.py ← Config load/save including storage/watch settings
│ │ └── file_watcher.py ← watchdog-based PDF watcher + startup scan + ingestion
│ ├── alembic/versions/ ← Doc-service migration chain
│ │ ── 0003_add_watch_columns.py ← source, watch_path, suggested_folder, suggested_filename
│ │ ── 0003_add_watch_columns.py ← source, watch_path, suggested_folder, suggested_filename
│ │ └── 0004_add_document_shares.py ← document_shares table (group-based sharing)
│ ├── Dockerfile
│ └── STATUS.md
@@ -170,8 +173,11 @@ docker compose up --build -d
│ │ ├── useAuth.ts ← Token state (localStorage), login/logout
│ │ └── useTheme.ts ← Theme toggle
│ ├── components/
│ │ ├── AppShell.tsx ← Layout: Sidebar + scrollable main
│ │ ├── AppShell.tsx ← Layout: Sidebar + SourcePanel (on /apps/documents) + main
│ │ ├── Sidebar.tsx ← Collapsible nav (icons ↔ icons+labels)
│ │ ├── SourcePanel.tsx ← Views + searchable category tree (docs route only)
│ │ ├── ManageCategoriesDialog.tsx ← Category CRUD modal (rename, delete)
│ │ ├── DocumentSlideOver.tsx ← Right slide-over: detail, edit, share, AI suggestions
│ │ ├── ThemeToggle.tsx ← Light/dark mode toggle
│ │ ├── PluginSchemaForm.tsx ← JSON Schema → React form (boolean/string/number/readOnly)
│ │ └── ui/ ← shadcn/ui components (Button, Input, …)
@@ -205,8 +211,8 @@ Browser (:5173 dev / :80 prod)
/users /groups /documents/categories/*
/profile /settings
/services │ │
JSON volume proxy (injects x-user-id)
(/config) │
JSON volume proxy (injects x-user-id,
(/config) x-user-groups)
doc-service:8001
ai-service:8010
@@ -314,6 +320,18 @@ Unique constraint: `(group_id, user_id)`
| `document_id` | String | PK + FK→documents.id CASCADE |
| `category_id` | String | PK + FK→document_categories.id CASCADE |
**`document_shares`**
| Column | Type | Constraints | Notes |
|--------|------|-------------|-------|
| `id` | String | PK, UUID | |
| `document_id` | String | indexed, NOT NULL | not FK — trusts proxy |
| `group_id` | String | indexed, NOT NULL | group from backend |
| `shared_by_user_id` | String | NOT NULL | owner who shared |
| `created_at` | DateTime(tz) | server_default=now() | |
Unique constraint: `(document_id, group_id)`
### Migration chains
**Backend** (must be applied in order):
@@ -333,6 +351,7 @@ Unique constraint: `(group_id, user_id)`
| `0001` | `create_doc_tables` |
| `0002` | `add_document_title` |
| `0003` | `add_watch_columns` |
| `0004` | `add_document_shares` |
---
@@ -353,6 +372,7 @@ Unique constraint: `(group_id, user_id)`
| GET | `/api/users/me/preferences` | user | Dashboard pinned app IDs → `{app_ids}` |
| PATCH | `/api/users/me/preferences` | user | Save pinned app IDs (max 50, slug-safe) |
| PATCH | `/api/users/me/color-mode` | user | Save colour mode preference ("light"/"dark"/"system") |
| GET | `/api/users/me/groups` | user | Groups current user belongs to → `list[UserGroupOut]` |
### Profile (`/api/profile`) — authenticated
@@ -426,6 +446,10 @@ Unique constraint: `(group_id, user_id)`
| POST | `/api/documents/{id}/suggestions/folder/reject` | Reject AI folder suggestion |
| POST | `/api/documents/{id}/suggestions/filename/confirm` | Confirm AI filename suggestion |
| POST | `/api/documents/{id}/suggestions/filename/reject` | Reject AI filename suggestion |
| GET | `/api/documents/shared-with-me` | Documents shared with current user via their groups |
| GET | `/api/documents/{id}/shares` | List groups the document is shared with (owner only) |
| POST | `/api/documents/{id}/shares` | Share with a group (owner only; group must be in user's groups) |
| DELETE | `/api/documents/{id}/shares/{group_id}` | Stop sharing with a group (owner only) |
### Categories (`/api/documents/categories/*`) — authenticated, proxied to doc-service
@@ -596,7 +620,10 @@ Adding a new API call:
["dashboard-prefs"] // user dashboard preferences
["categories"] // document categories
["documents", params] // document list (params object for cache isolation)
["documents-shared", params] // shared-with-me list
["document", id] // single document
["document-shares", id] // share list for a specific document
["my-groups"] // current user's group memberships (for share picker)
["plugins"] // accessible plugin list (filtered by user access)
["plugin-manifest", id] // plugin manifest (cached)
["plugin-settings", id] // plugin current settings
+1
View File
@@ -27,6 +27,7 @@ JWT signing uses a 4096-bit RSA key pair (`RS256`). Keys are generated by `scrip
| `GET` | `/api/users/me` | Current user info |
| `GET` | `/api/users/me/preferences` | User's dashboard preferences (`app_ids` list) |
| `PATCH` | `/api/users/me/preferences` | Update pinned app IDs (max 50; validated as safe slugs) |
| `GET` | `/api/users/me/groups` | List groups the current user belongs to (for share picker) |
### Profile (`/api/profile`)
+12 -2
View File
@@ -9,8 +9,12 @@ import os
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.deps import get_current_user
from app.models.group import GroupMembership
from app.models.user import User
DOC_SERVICE_URL = os.environ.get("DOC_SERVICE_URL", "http://doc-service:8001")
@@ -35,13 +39,18 @@ _HOP_BY_HOP = frozenset([
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
def _forward_headers(request: Request, user_id: str) -> dict:
async def _forward_headers(request: Request, user_id: str, db: AsyncSession) -> dict:
headers = {
k: v
for k, v in request.headers.items()
if k.lower() not in _HOP_BY_HOP
}
headers["x-user-id"] = user_id
result = await db.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)
group_ids = [row[0] for row in result.all()]
headers["x-user-groups"] = ",".join(group_ids)
return headers
@@ -50,10 +59,11 @@ def _forward_headers(request: Request, user_id: str) -> dict:
async def proxy_categories(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
path: str = "",
) -> Response:
url = f"/categories/{path}" if path else "/categories"
headers = _forward_headers(request, str(current_user.id))
headers = await _forward_headers(request, str(current_user.id), db)
body = await request.body()
try:
+19 -2
View File
@@ -3,14 +3,21 @@ Proxy all /api/documents/* requests to doc-service:8001/documents/*.
Uses a module-level AsyncClient for connection pooling.
Strips hop-by-hop headers that must not be forwarded.
Injects X-User-Id and X-User-Groups headers so the doc-service
can enforce ownership and group-sharing access without querying the
backend database directly.
"""
import os
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.deps import get_current_user
from app.models.group import GroupMembership
from app.models.user import User
DOC_SERVICE_URL = os.environ.get("DOC_SERVICE_URL", "http://doc-service:8001")
@@ -43,13 +50,22 @@ _HOP_BY_HOP = frozenset([
_STRIP_RESPONSE = frozenset([*_HOP_BY_HOP, "content-length", "content-type"])
def _forward_headers(request: Request, user_id: str) -> dict:
async def _forward_headers(request: Request, user_id: str, db: AsyncSession) -> dict:
headers = {
k: v
for k, v in request.headers.items()
if k.lower() not in _HOP_BY_HOP
}
headers["x-user-id"] = user_id
# Inject the user's group memberships so the doc-service can evaluate
# group-shared document access without querying the backend DB.
result = await db.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)
group_ids = [row[0] for row in result.all()]
headers["x-user-groups"] = ",".join(group_ids)
return headers
@@ -58,10 +74,11 @@ def _forward_headers(request: Request, user_id: str) -> dict:
async def proxy_documents(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
path: str = "",
) -> Response:
url = f"/documents/{path}" if path else "/documents"
headers = _forward_headers(request, str(current_user.id))
headers = await _forward_headers(request, str(current_user.id), db)
body = await request.body()
try:
+19 -1
View File
@@ -1,10 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.deps import get_current_user
from app.models.group import Group, GroupMembership
from app.models.user import User
from app.schemas.user import ColorModeUpdate, DashboardPrefsOut, DashboardPrefsUpdate, UserOut
from app.schemas.user import ColorModeUpdate, DashboardPrefsOut, DashboardPrefsUpdate, UserGroupOut, UserOut
router = APIRouter()
@@ -31,6 +34,21 @@ async def update_preferences(
return DashboardPrefsOut(app_ids=current_user.dashboard_app_ids or [])
@router.get("/me/groups", response_model=list[UserGroupOut])
async def get_my_groups(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return all groups the current user belongs to."""
result = await db.execute(
select(Group)
.join(GroupMembership, GroupMembership.group_id == Group.id)
.where(GroupMembership.user_id == current_user.id)
.order_by(Group.name)
)
return result.scalars().all()
@router.patch("/me/color-mode", response_model=UserOut)
async def update_color_mode(
body: ColorModeUpdate,
+10
View File
@@ -1,4 +1,5 @@
import re
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field, field_validator
@@ -116,6 +117,15 @@ class ColorModeUpdate(BaseModel):
return v
class UserGroupOut(BaseModel):
"""A group the current user belongs to — used for the share picker."""
id: str
name: str
description: str | None
model_config = {"from_attributes": True}
class DashboardPrefsUpdate(BaseModel):
app_ids: list[str] = Field(default_factory=list)
@@ -0,0 +1,43 @@
# 2026-04-18 — Doc Service Redesign: Scalable UX + Group-Based Sharing
**Timestamp:** 2026-04-18T00:00:00+00:00
## Summary
Complete redesign of the document management UX for scale (10 → 100 000 documents, 2 → 1 000 categories) and group-based document sharing. Replaced the monolithic DocumentsPage with a three-column layout (Sidebar + SourcePanel + main), a slide-over detail panel, a filter chip system, multi-file upload queue, and bulk actions. Added the full backend sharing stack: `document_shares` table, share CRUD endpoints, a shared-with-me view, and X-User-Groups header injection in the gateway proxy.
---
## Files Added
| File | Description |
|------|-------------|
| `features/doc-service/app/models/document_share.py` | DocumentShare ORM model (document_id, group_id, shared_by_user_id) |
| `features/doc-service/app/schemas/share.py` | DocumentShareOut, DocumentShareCreate, SharedDocumentOut schemas |
| `features/doc-service/alembic/versions/0004_add_document_shares.py` | Migration creating document_shares table with indexes |
| `frontend/src/components/SourcePanel.tsx` | Left panel (240px): views (All/Mine/Shared) + searchable category tree + new category form |
| `frontend/src/components/ManageCategoriesDialog.tsx` | Category CRUD modal (inline rename, delete with confirm, search) |
| `frontend/src/components/DocumentSlideOver.tsx` | Right slide-over (480px): metadata, inline title edit, type picker, AI suggestions, categories combobox, tags, sharing section, raw text, actions |
---
## Files Modified
| File | Change |
|------|--------|
| `features/doc-service/app/models/__init__.py` | Import DocumentShare |
| `features/doc-service/app/deps.py` | Added `get_user_groups` dependency (reads X-User-Groups header) |
| `features/doc-service/app/schemas/document.py` | Added `share_count: int = 0` to DocumentOut |
| `features/doc-service/app/routers/documents.py` | Complete rewrite: added share CRUD endpoints, shared-with-me endpoint, N+1-safe share_count, recipient download access, X-User-Groups enforcement |
| `features/doc-service/STATUS.md` | Added sharing section, migration 0004, updated future work |
| `backend/app/routers/documents_proxy.py` | Injects X-User-Groups header (queries GroupMembership per request) |
| `backend/app/routers/categories_proxy.py` | Same X-User-Groups injection pattern |
| `backend/app/routers/users.py` | Added GET /me/groups endpoint |
| `backend/app/schemas/user.py` | Added UserGroupOut schema |
| `backend/STATUS.md` | Added /me/groups endpoint |
| `frontend/src/api/client.ts` | Added share_count to DocumentOut, SharedDocumentOut, DocumentShareOut, listSharedWithMe, getDocumentShares, addDocumentShare, removeDocumentShare, getMyGroups |
| `frontend/src/components/Sidebar.tsx` | Removed per-category NavLinks; Documents is now a single NavLink under Apps |
| `frontend/src/components/AppShell.tsx` | Renders SourcePanel between Sidebar and main on /apps/documents route |
| `frontend/src/pages/DocumentsPage.tsx` | Complete rewrite: three-panel layout, view param (all/mine/shared), smart polling, drag-and-drop, multi-file upload queue, filter chip system, bulk actions bar |
| `frontend/STATUS.md` | Complete rewrite reflecting all new components and patterns |
| `CLAUDE.md` | Updated file tree, Database Models (DocumentShare), Migration chains (0004), API endpoints (shares, shared-with-me, /me/groups), TanStack Query keys, request flow diagram |
+24 -5
View File
@@ -123,6 +123,23 @@ Controlled via plugin settings (UI accessible to superusers and `doc-service-adm
On startup scan, the watcher walks the watch directory and ingests any PDFs not already in the database (idempotency check by `watch_path`). Subfolders are automatically mapped to categories (e.g. `watch/invoices/bill.pdf` → category "invoices"). No-remove policy: deleting a file from the watch directory does not delete the document record.
### Document sharing (`document_shares`)
Group-based sharing allows a document owner to share a document with all members of any group they belong to. Recipients can view and download the shared document; they cannot edit, re-analyse, delete, or re-share it.
The gateway injects `X-User-Groups: <group_id1>,<group_id2>,...` alongside the existing `X-User-Id` header, so doc-service can evaluate group access without querying the backend DB.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/documents/shared-with-me` | Any user | Documents shared with the user via their groups; excludes own docs |
| `GET` | `/documents/{id}/shares` | Owner only | List all groups the document is shared with |
| `POST` | `/documents/{id}/shares` | Owner only | Share with a group (`{group_id}` in body); group must be in X-User-Groups |
| `DELETE` | `/documents/{id}/shares/{group_id}` | Owner only | Stop sharing with that group |
`DocumentOut` now includes `share_count: int` — the number of groups the document is shared with.
`GET /documents/{id}/file` also allows access to shared documents (recipients can download).
### Database migrations
| Revision | Description |
@@ -130,6 +147,7 @@ On startup scan, the watcher walks the watch directory and ingests any PDFs not
| 0001 | Initial schema (documents, categories, category_assignments) |
| 0002 | Add `title` column to documents |
| 0003 | Add `source`, `watch_path`, `suggested_folder`, `suggested_filename` columns |
| 0004 | Add `document_shares` table (document_id, group_id, shared_by_user_id, created_at) |
Run automatically on container start via `alembic upgrade head`.
@@ -168,8 +186,8 @@ file_watcher.py (watchdog Observer, daemon thread)
- **Re-process** — no endpoint to re-trigger AI extraction on an existing document (e.g. after changing the AI model or prompt)
- **Advanced field-level search** — `search` param matches text fields via ILIKE but does not query into `extracted_data` JSON (e.g. filter by `vendor` or `due_date`)
- **Bulk operations** — no bulk category assign/remove, no bulk delete
- **Document sharing** — documents are strictly per-user; no group sharing yet
- **Bulk operations** — no bulk category assign/remove endpoint (frontend handles bulk delete/share individually)
- **Advanced field-level search** — `search` matches text fields via ILIKE but does not query into `extracted_data` JSON
- **Pagination in categories** — categories are returned as a full list (no pagination)
- **File type** — only PDF supported
- **Concurrent uploads** — no rate limiting per user
@@ -183,9 +201,10 @@ file_watcher.py (watchdog Observer, daemon thread)
- [x] Plugin manifest endpoint (`/plugin/manifest`, `/plugin/settings`) for generic settings UI
- [ ] Advanced filter: query `extracted_data` JSON fields (vendor, due_date, amount) — requires PostgreSQL `jsonb` column or indexed virtual columns
- [ ] Bulk operations endpoint
- [ ] Document sharing via groups (blocked on groups/permissions system in backend)
- [x] Document sharing via groups `document_shares` table + share endpoints + shared-with-me view
- [x] Frontend UI for suggestion badges (suggested_folder / suggested_filename confirm/reject buttons in slide-over)
- [ ] Advanced filter: query `extracted_data` JSON fields (vendor, due_date, amount)
- [ ] Support additional file types (images via OCR, DOCX)
- [ ] Rate limiting on upload endpoint
- [ ] Soft delete with restore
- [ ] Category rename / delete with cascade handling
- [ ] Frontend UI for suggestion badges (suggested_folder / suggested_filename confirm/reject buttons)
- [ ] Edit rights for shared recipients (V2)
@@ -0,0 +1,42 @@
"""add document_shares table
Revision ID: 0004
Revises: 0003
Create Date: 2026-04-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0004"
down_revision: Union[str, None] = "0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"document_shares",
sa.Column("id", sa.String(), nullable=False),
sa.Column("document_id", sa.String(), nullable=False),
sa.Column("group_id", sa.String(), nullable=False),
sa.Column("shared_by_user_id", sa.String(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("document_id", "group_id", name="uq_document_group_share"),
)
op.create_index("ix_document_shares_document_id", "document_shares", ["document_id"])
op.create_index("ix_document_shares_group_id", "document_shares", ["group_id"])
def downgrade() -> None:
op.drop_index("ix_document_shares_group_id", table_name="document_shares")
op.drop_index("ix_document_shares_document_id", table_name="document_shares")
op.drop_table("document_shares")
+11
View File
@@ -10,3 +10,14 @@ async def get_user_id(x_user_id: str = Header(...)) -> str:
if not x_user_id:
raise HTTPException(status_code=400, detail="Missing X-User-Id header")
return x_user_id
async def get_user_groups(x_user_groups: str = Header(default="")) -> list[str]:
"""
Extract the group IDs injected by the main backend proxy.
Comma-separated list of group UUIDs the current user belongs to.
Returns an empty list if the header is absent or empty.
"""
if not x_user_groups:
return []
return [g.strip() for g in x_user_groups.split(",") if g.strip()]
+2 -1
View File
@@ -1,5 +1,6 @@
from app.models.document import Document
from app.models.category import DocumentCategory
from app.models.category_assignment import CategoryAssignment
from app.models.document_share import DocumentShare
__all__ = ["Document", "DocumentCategory", "CategoryAssignment"]
__all__ = ["Document", "DocumentCategory", "CategoryAssignment", "DocumentShare"]
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DocumentShare(Base):
__tablename__ = "document_shares"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
document_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
group_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
shared_by_user_id: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
__table_args__ = (
UniqueConstraint("document_id", "group_id", name="uq_document_group_share"),
)
+246 -15
View File
@@ -13,11 +13,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import AsyncSessionLocal, get_db
from app.deps import get_user_id
from app.deps import get_user_groups, 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.document import DocumentOut, DocumentPage, DocumentStatusOut, DocumentTypeUpdate, TagsUpdate, TitleUpdate
from app.models.document_share import DocumentShare
from app.schemas.document import (
DocumentOut,
DocumentPage,
DocumentStatusOut,
DocumentTypeUpdate,
TagsUpdate,
TitleUpdate,
)
from app.schemas.share import DocumentShareCreate, DocumentShareOut, SharedDocumentOut
from app.services.ai_client import AIServiceError, classify_document
from app.services.config_reader import load_doc_config
from app.services.storage import delete_file, get_upload_path, save_upload
@@ -52,7 +61,19 @@ async def _get_user_doc(doc_id: str, user_id: str, db: AsyncSession) -> Document
return doc
def _doc_with_categories(doc: Document) -> DocumentOut:
async def _get_share_counts(doc_ids: list[str], db: AsyncSession) -> dict[str, int]:
"""Return a mapping of doc_id → share count for the given document IDs."""
if not doc_ids:
return {}
rows = await db.execute(
select(DocumentShare.document_id, func.count(DocumentShare.id))
.where(DocumentShare.document_id.in_(doc_ids))
.group_by(DocumentShare.document_id)
)
return {row[0]: row[1] for row in rows.all()}
def _doc_with_categories(doc: Document, share_count: int = 0) -> DocumentOut:
from app.schemas.document import CategoryOut
cats = [CategoryOut(id=a.category.id, name=a.category.name) for a in doc.category_assignments]
return DocumentOut(
@@ -73,6 +94,7 @@ def _doc_with_categories(doc: Document) -> DocumentOut:
watch_path=doc.watch_path,
suggested_folder=doc.suggested_folder,
suggested_filename=doc.suggested_filename,
share_count=share_count,
)
@@ -161,8 +183,6 @@ async def upload_document(
background_tasks.add_task(process_document, doc_id)
# Re-query with selectinload so category_assignments is eagerly loaded.
# A new doc has no categories yet, but we need the relationship populated
# to avoid MissingGreenlet in the async session.
doc = await _get_user_doc(doc_id, user_id, db)
return _doc_with_categories(doc)
@@ -194,7 +214,6 @@ async def list_documents(
sort_col = _SORT_COLUMNS.get(sort, Document.created_at)
sort_expr = sort_col.desc() if order == "desc" else sort_col.asc()
# Build filter conditions once and reuse for both count + items queries.
# Watch-ingested documents (user_id = "watch") are visible to all users.
conditions = [or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID)]
if status:
@@ -233,7 +252,10 @@ async def list_documents(
.offset((page - 1) * per_page)
.limit(per_page)
)
items = [_doc_with_categories(d) for d in items_result.scalars().all()]
docs = items_result.scalars().all()
share_counts = await _get_share_counts([d.id for d in docs], db)
items = [_doc_with_categories(d, share_counts.get(d.id, 0)) for d in docs]
return DocumentPage(
items=items,
@@ -243,6 +265,119 @@ async def list_documents(
)
# NOTE: This route must be registered BEFORE /{doc_id} to avoid path collision.
@router.get("/shared-with-me", response_model=DocumentPage)
async def list_shared_with_me(
page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=100),
sort: str = Query(default="created_at"),
order: str = Query(default="desc", pattern="^(asc|desc)$"),
search: str | None = Query(default=None),
document_type: str | None = Query(default=None),
user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
db: AsyncSession = Depends(get_db),
) -> DocumentPage:
"""Return documents shared with the current user via any of their groups.
Excludes documents the user owns (those appear in their regular list).
"""
if not user_groups:
return DocumentPage(items=[], total=0, page=page, pages=1)
sort_col = _SORT_COLUMNS.get(sort, Document.created_at)
sort_expr = sort_col.desc() if order == "desc" else sort_col.asc()
shared_doc_ids_subq = (
select(DocumentShare.document_id)
.where(DocumentShare.group_id.in_(user_groups))
.scalar_subquery()
)
conditions = [
Document.id.in_(shared_doc_ids_subq),
Document.user_id != user_id, # exclude own docs
]
if document_type:
conditions.append(Document.document_type == document_type)
if search:
like = f"%{search}%"
conditions.append(
or_(
Document.title.ilike(like),
Document.filename.ilike(like),
Document.tags.ilike(like),
Document.document_type.ilike(like),
)
)
count_result = await db.execute(
select(func.count(Document.id)).where(*conditions)
)
total = count_result.scalar_one()
items_result = await db.execute(
select(Document)
.where(*conditions)
.options(
selectinload(Document.category_assignments)
.selectinload(CategoryAssignment.category)
)
.order_by(sort_expr)
.offset((page - 1) * per_page)
.limit(per_page)
)
docs = items_result.scalars().all()
# For each doc, find which share (group) brought it in (pick first match)
share_rows_result = await db.execute(
select(DocumentShare)
.where(
DocumentShare.document_id.in_([d.id for d in docs]),
DocumentShare.group_id.in_(user_groups),
)
)
share_rows = share_rows_result.scalars().all()
# Map doc_id → first share row found
share_map: dict[str, DocumentShare] = {}
for share in share_rows:
if share.document_id not in share_map:
share_map[share.document_id] = share
from app.schemas.document import CategoryOut
items: list[SharedDocumentOut] = []
for doc in docs:
cats = [CategoryOut(id=a.category.id, name=a.category.name) for a in doc.category_assignments]
share = share_map.get(doc.id)
items.append(
SharedDocumentOut(
id=doc.id,
user_id=doc.user_id,
filename=doc.filename,
title=doc.title,
file_size=doc.file_size,
status=doc.status,
document_type=doc.document_type,
extracted_data=doc.extracted_data,
tags=doc.tags,
error_message=doc.error_message,
created_at=doc.created_at,
processed_at=doc.processed_at,
categories=cats,
source=doc.source,
shared_by_user_id=share.shared_by_user_id if share else "",
shared_via_group_id=share.group_id if share else "",
)
)
return DocumentPage(
items=items, # type: ignore[arg-type]
total=total,
page=page,
pages=max(1, math.ceil(total / per_page)),
)
@router.get("/{doc_id}", response_model=DocumentOut)
async def get_document(
doc_id: str,
@@ -250,7 +385,8 @@ async def get_document(
db: AsyncSession = Depends(get_db),
) -> DocumentOut:
doc = await _get_user_doc(doc_id, user_id, db)
return _doc_with_categories(doc)
counts = await _get_share_counts([doc.id], db)
return _doc_with_categories(doc, counts.get(doc.id, 0))
@router.get("/{doc_id}/status", response_model=DocumentStatusOut)
@@ -360,12 +496,22 @@ async def delete_document(
async def download_file(
doc_id: str,
user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
db: AsyncSession = Depends(get_db),
) -> StreamingResponse:
# Allow access if: owner, watch doc, or shared with any of user's groups
result = await db.execute(
select(Document).where(
Document.id == doc_id,
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
or_(
Document.user_id == user_id,
Document.user_id == _WATCH_USER_ID,
Document.id.in_(
select(DocumentShare.document_id).where(
DocumentShare.group_id.in_(user_groups)
)
) if user_groups else False,
),
)
)
doc = result.scalar_one_or_none()
@@ -393,7 +539,6 @@ async def assign_category(
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db),
) -> None:
# Verify the document is accessible (own or watch-ingested)
doc_result = await db.execute(
select(Document).where(
Document.id == doc_id,
@@ -411,7 +556,6 @@ async def assign_category(
if cat_result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Category not found")
# Upsert — ignore if already assigned
existing = await db.execute(
select(CategoryAssignment).where(
CategoryAssignment.document_id == doc_id,
@@ -443,8 +587,6 @@ async def remove_category(
# ── AI suggestion confirmation ────────────────────────────────────────────────
# These endpoints allow users to confirm or reject AI suggestions on
# watch-ingested documents. No disk mutations — suggestions only update the DB.
@router.post("/{doc_id}/suggestions/folder/confirm", status_code=204)
async def confirm_folder_suggestion(
@@ -456,7 +598,6 @@ async def confirm_folder_suggestion(
if not doc.suggested_folder:
raise HTTPException(status_code=400, detail="No folder suggestion pending")
# Find or create the suggested category under the watch sentinel user
cat_result = await db.execute(
select(DocumentCategory).where(
DocumentCategory.user_id == _WATCH_USER_ID,
@@ -470,7 +611,6 @@ async def confirm_folder_suggestion(
await db.commit()
await db.refresh(cat)
# Assign if not already assigned
exists = await db.execute(
select(CategoryAssignment).where(
CategoryAssignment.document_id == doc_id,
@@ -518,3 +658,94 @@ async def reject_filename_suggestion(
doc = await _get_user_doc(doc_id, user_id, db)
doc.suggested_filename = None
await db.commit()
# ── Document sharing ──────────────────────────────────────────────────────────
@router.get("/{doc_id}/shares", response_model=list[DocumentShareOut])
async def list_document_shares(
doc_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db),
) -> list[DocumentShare]:
"""List all group shares for a document. Owner only."""
result = await db.execute(
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
)
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Document not found")
shares_result = await db.execute(
select(DocumentShare).where(DocumentShare.document_id == doc_id)
)
return shares_result.scalars().all()
@router.post("/{doc_id}/shares", response_model=DocumentShareOut, status_code=201)
async def add_document_share(
doc_id: str,
body: DocumentShareCreate,
user_id: str = Depends(get_user_id),
user_groups: list[str] = Depends(get_user_groups),
db: AsyncSession = Depends(get_db),
) -> DocumentShare:
"""Share a document with a group. The sharing user must own the document
and must be a member of the target group."""
result = await db.execute(
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
)
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Document not found")
if body.group_id not in user_groups:
raise HTTPException(
status_code=403,
detail="You can only share with groups you belong to",
)
# Idempotent — return existing share if already shared with this group
existing = await db.execute(
select(DocumentShare).where(
DocumentShare.document_id == doc_id,
DocumentShare.group_id == body.group_id,
)
)
share = existing.scalar_one_or_none()
if share is not None:
return share
share = DocumentShare(
document_id=doc_id,
group_id=body.group_id,
shared_by_user_id=user_id,
)
db.add(share)
await db.commit()
await db.refresh(share)
return share
@router.delete("/{doc_id}/shares/{group_id}", status_code=204)
async def remove_document_share(
doc_id: str,
group_id: str,
user_id: str = Depends(get_user_id),
db: AsyncSession = Depends(get_db),
) -> None:
"""Remove a group share. Owner only."""
result = await db.execute(
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
)
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=404, detail="Document not found")
share_result = await db.execute(
select(DocumentShare).where(
DocumentShare.document_id == doc_id,
DocumentShare.group_id == group_id,
)
)
share = share_result.scalar_one_or_none()
if share:
await db.delete(share)
await db.commit()
@@ -27,6 +27,7 @@ class DocumentOut(BaseModel):
watch_path: str | None = None
suggested_folder: str | None = None
suggested_filename: str | None = None
share_count: int = 0
model_config = {"from_attributes": True}
+41
View File
@@ -0,0 +1,41 @@
from datetime import datetime
from pydantic import BaseModel
class DocumentShareOut(BaseModel):
id: str
document_id: str
group_id: str
shared_by_user_id: str
created_at: datetime
model_config = {"from_attributes": True}
class DocumentShareCreate(BaseModel):
group_id: str
class SharedDocumentOut(BaseModel):
"""DocumentOut fields plus sharing context for the 'Shared with me' view."""
id: str
user_id: str
filename: str
title: str | None
file_size: int
status: str
document_type: str | None
extracted_data: str | None
tags: str | None
error_message: str | None
created_at: datetime
processed_at: datetime | None
categories: list = []
source: str = "upload"
share_count: int = 0
# Sharing context
shared_by_user_id: str
shared_via_group_id: str
model_config = {"from_attributes": True}
+102 -110
View File
@@ -40,138 +40,127 @@ All API calls go through `src/api/client.ts` (single Axios instance, JWT injecte
### Home dashboard (`/`)
Personalised landing page per user:
- Time-aware greeting with the user's display name (`full_name` or email). React JSX text rendering HTML-escapes all values — no `dangerouslySetInnerHTML` is used anywhere on this page.
- Grid of **pinned app cards** drawn from `GET /api/services`, filtered to the user's saved list.
- **Customize mode** (pencil button): shows all services; `+` / `` toggle buttons on each card; changes committed with **Save** via `PATCH /api/users/me/preferences`.
- Empty-state prompt when no apps are pinned.
- Time-aware greeting with the user's display name
- Grid of **pinned app cards** from `GET /api/services`, filtered to user's saved list
- **Customize mode** (pencil button): shows all services; `+` / `` toggle; commits via `PATCH /api/users/me/preferences`
### Apps page (`/apps`)
Cards are rendered dynamically from `GET /api/services` (polled every 30 s via TanStack Query):
- **healthy=true + app_path set** — clickable card with "Available" badge
- **healthy=true + no app_path** — non-clickable card (e.g. AI Service — no user UI)
- **healthy=false** — non-clickable, dimmed card with "Unavailable" badge and explanation text
- Single **Settings** button per card — visible to global admins OR members of the service's admin group (checked via `GET /api/plugins` which backend filters by access). Links to `svc.settings_path`.
Cards from `GET /api/services` (polled every 30 s):
- healthy + app_path clickable card with "Available" badge
- healthy + no app_path non-clickable card
- unhealthy → dimmed, non-clickable, "Unavailable"
- Settings button visible to admins and service-admin group members
### 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`
`Apps` expandable accordion: **Documents** single NavLink to `/apps/documents`. Category navigation moved to SourcePanel (only visible on `/apps/documents` route). Admin section (Users, Groups, Appearance) for admins. Collapsible to icon-only mode.
### Documents page (`/apps/documents`)
### Documents page (`/apps/documents`) — three-column layout
**Upload:** PDF file input, 202 response, error display.
**SourcePanel** (240px, left): Appears only on `/apps/documents`.
- Views: All Documents / Mine / Shared with me (URL param `?view=`)
- Category tree with client-side search (searchable when > 4 categories)
- Inline new category form
- "Manage categories" button opens `ManageCategoriesDialog`
**Filter bar:**
- Search input (400ms debounce) — matches title, filename, tags, document_type
- Status dropdown (all / pending / processing / done / failed)
- Type dropdown (all / invoice / bill / receipt / order / expense / revenue / unknown)
- Sort selector (upload date / processed date / title / filename / file size / type / status)
- Asc/Desc toggle
- "Clear filters" button (appears when any filter is active)
**Toolbar:** Debounced search input (400ms) + filter chips system.
- Filter chips: Status, Document type, Category (each adds a removable chip)
- "Add filter" button opens a two-step picker (dimension → value)
- Sort via clickable column headers (↑/↓ chevron)
**Pagination:** Prev/Next with "XY of Z" count. Only shown when total > per_page.
**Compact table rows:**
- Columns: checkbox | title/filename | type | status dot | categories (2 + overflow) | sharing icon | date | size | 3-dot actions
- Row click opens `DocumentSlideOver`
- Shared-with-me rows show a primary border accent
**Document row (collapsed):**
- Inline title editor (pencil icon, Enter to save, Esc to cancel; shows filename in italic when no title)
- Status badge (colour-coded)
- Document type label
- File size
- View button (opens PDF in new tab via blob URL — auth-gated)
- Download button
- Delete button (confirm dialog)
**DocumentSlideOver** (480px, right slide-over):
- Metadata (status dot, size, dates, source)
- Inline title edit (pencil icon)
- Type picker (chips for each doc type)
- **AI Suggestions** — folder and filename confirm/reject buttons (was missing before, now implemented)
- Extracted data key-value table
- Categories multi-select combobox (search-to-filter)
- AI-suggested categories with Assign / Create & Assign actions
- Tags chip editor (add/remove inline)
- **Sharing section** (owner only): lists groups the doc is shared with; group picker combobox (filtered to user's own groups); remove share button
- Raw text section (collapsed by default)
- Re-analyse / Delete actions (owner only)
**Document row (expanded):**
- **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
- **Status polling** — auto-refetches every 3s while status is pending/processing; invalidates document list on done/failed
**Bulk actions bar** (floating, bottom center, owner view only):
- Appears when any rows are checked
- Share with group (opens group picker → shares all selected)
- Delete (confirm dialog)
- Clear selection
**Upload experience:**
- Full-page drag-and-drop overlay (activates on `dragenter`)
- Multi-file upload (iterates all selected/dropped files)
- Bottom-right upload queue panel (collapsible toast) with per-file status + "Review →" link after upload
**Document sharing:**
- Owner shares doc with any of their own groups from the slide-over
- Recipient sees shared docs in "Shared with me" view
- Recipient can View + Download only (no edit/delete/share)
- `share_count` indicator (Users icon) in table rows
**Polling:** List query refetches every 3s automatically when any visible doc is pending/processing (single query, not per-document). Uses TanStack Query `refetchInterval` function.
### AI Service Settings (`/apps/ai/settings`)
Accessible to global admins and `ai-service-admin` group members (`ServiceAdminRoute`).
- Provider selector (lmstudio / ollama / anthropic)
- Per-provider fields (base URL, model, API key)
- Test Connection button (`POST /api/settings/ai/test`)
- Save button
Provider selector, per-provider fields, Test Connection, Save.
### Document Service Settings (`/apps/documents/settings`)
Accessible to global admins and `doc-service-admin` group members (`ServiceAdminRoute`).
Combined settings on one page, accessed via the single "Settings" button on the app card:
- **Upload Limits** — max PDF size in MB (`GET/PATCH /api/settings/documents/limits`)
- **Watch Directory** — file watcher config rendered via `PluginSchemaForm` from manifest (`GET/PATCH /api/plugins/doc-service/settings`)
Upload limits + watch directory config.
### Admin — Users page (`/admin/users`)
- User list with role and active status
- Inline active status toggle
- Create user form (email, name, password, admin flag)
- Delete user
User list, toggle active, create user, delete user.
### Admin — Groups page (`/admin/groups`)
- Group list with name, description, member count
- Create group (name, optional description)
- Edit group name / description inline panel
- Delete group (with confirmation)
- Expand group row to manage members: view members, remove members, add non-members from dropdown
Group list, create, edit name/description, delete, add/remove members.
### Profile page (`/profile`)
- Display and edit personal information
Display and edit personal information.
---
## API client (`src/api/client.ts`)
Key functions:
Key document-related functions:
| Function | Description |
|----------|-------------|
| `listDocuments(params)` | `GET /documents`returns `DocumentPage`; supports `category_id` filter |
| `listDocuments(params)` | `GET /documents`paginated with filters |
| `listSharedWithMe(params)` | `GET /documents/shared-with-me` |
| `uploadDocument(file)` | `POST /documents/upload` |
| `deleteDocument(id)` | `DELETE /documents/{id}` |
| `downloadDocument(id, filename)` | Blob URL download |
| `viewDocument(id)` | Blob URL → `window.open`, auto-revoke after 60s |
| `getDocumentStatus(id)` | Poll endpoint |
| `listCategories()` | All categories for user |
| `createCategory(name)` | Create category |
| `assignCategory(docId, catId)` | Assign |
| `removeCategory(docId, catId)` | Remove |
| `updateDocumentTags(id, tags)` | `PATCH /documents/{id}/tags` |
| `updateDocumentTitle(id, title)` | `PATCH /documents/{id}/title` |
| `confirmFolderSuggestion(docId)` | `POST /documents/{id}/suggestions/folder/confirm` |
| `rejectFolderSuggestion(docId)` | `POST /documents/{id}/suggestions/folder/reject` |
| `confirmFilenameSuggestion(docId)` | `POST /documents/{id}/suggestions/filename/confirm` |
| `rejectFilenameSuggestion(docId)` | `POST /documents/{id}/suggestions/filename/reject` |
| `getAISettings()` | `GET /settings/ai` (masked) |
| `updateAISettings(data)` | `PATCH /settings/ai` |
| `testAIConnection()` | `POST /settings/ai/test` |
| `getDocumentLimits()` | `GET /settings/documents/limits` |
| `adminListGroups()` | `GET /admin/groups` |
| `adminCreateGroup(data)` | `POST /admin/groups` |
| `adminGetGroup(id)` | `GET /admin/groups/{id}` with members |
| `adminUpdateGroup(id, data)` | `PATCH /admin/groups/{id}` |
| `adminDeleteGroup(id)` | `DELETE /admin/groups/{id}` |
| `adminAddGroupMember(gId, uId)` | `POST /admin/groups/{gId}/members/{uId}` |
| `adminRemoveGroupMember(gId, uId)` | `DELETE /admin/groups/{gId}/members/{uId}` |
| `updateDocumentLimits(data)` | `PATCH /settings/documents/limits` |
| `getPlugins()` | `GET /plugins` — list accessible plugins |
| `getPluginManifest(id)` | `GET /plugins/{id}/manifest` |
| `getPluginSettings(id)` | `GET /plugins/{id}/settings` |
| `updatePluginSettings(id, data)` | `PATCH /plugins/{id}/settings` |
| `viewDocument(id)` | Blob URL → new tab, 60s revoke |
| `getDocumentShares(docId)` | `GET /documents/{id}/shares` |
| `addDocumentShare(docId, groupId)` | `POST /documents/{id}/shares` |
| `removeDocumentShare(docId, groupId)` | `DELETE /documents/{id}/shares/{group_id}` |
| `getMyGroups()` | `GET /users/me/groups` (for share picker) |
| `confirmFolderSuggestion(docId)` | Apply AI folder suggestion |
| `rejectFolderSuggestion(docId)` | Dismiss AI folder suggestion |
| `confirmFilenameSuggestion(docId)` | Apply AI filename suggestion |
| `rejectFilenameSuggestion(docId)` | Dismiss AI filename suggestion |
---
## State management
- **TanStack Query** — all server state; `queryKey: ["documents", params]` for cache isolation per filter/page combination
- **No global store** — local `useState` for UI-only state (editing mode, filter params, etc.)
- **Token** — `localStorage`, read by `useAuth` hook, injected by Axios interceptor
- **TanStack Query** — all server state
- `["documents", params]` — owned doc list (refetchInterval when pending/processing)
- `["documents-shared", params]` — shared-with-me list
- `["categories"]` — all user categories (shared across SourcePanel + DocumentSlideOver)
- `["document-shares", docId]` — shares for a specific document
- `["my-groups"]` — current user's group memberships
- **URL search params** — `view`, `page`, `sort`, `order`, `search`, `status`, `document_type`, `category_id`
- **Local `useState`** — UI-only state (drag, upload queue, active doc ID, selected IDs, slide-over open)
---
@@ -179,37 +168,40 @@ Key functions:
| Component | Path | Description |
|-----------|------|-------------|
| `AppShell` | `src/components/AppShell.tsx` | Layout wrapper: Sidebar + scrollable main content |
| `Sidebar` | `src/components/Sidebar.tsx` | Collapsible left nav; includes dynamic "Extensions" section |
| `ThemeToggle` | `src/components/ThemeToggle.tsx` | Sun/moon ghost icon button; persists to localStorage |
| `PluginSchemaForm` | `src/components/PluginSchemaForm.tsx` | JSON Schema → React form (boolean/string/number/readOnly fields) |
| `PluginSettingsPage` | `src/pages/PluginSettingsPage.tsx` | Generic plugin settings page (manifest-driven) |
| `Button` | `src/components/ui/button.tsx` | shadcn/ui Button (default, ghost, outline, destructive) |
| `AppShell` | `src/components/AppShell.tsx` | Layout: Sidebar + SourcePanel (on /apps/documents) + main |
| `Sidebar` | `src/components/Sidebar.tsx` | Collapsible left nav (categories removed, replaced by SourcePanel) |
| `SourcePanel` | `src/components/SourcePanel.tsx` | Views + searchable category tree (docs route only) |
| `ManageCategoriesDialog` | `src/components/ManageCategoriesDialog.tsx` | Category CRUD modal |
| `DocumentSlideOver` | `src/components/DocumentSlideOver.tsx` | Right slide-over: detail, edit, share, AI suggestions |
| `ThemeToggle` | `src/components/ThemeToggle.tsx` | Sun/moon toggle |
| `PluginSchemaForm` | `src/components/PluginSchemaForm.tsx` | JSON Schema → React form |
| `Button` | `src/components/ui/button.tsx` | shadcn/ui Button |
| `Input` | `src/components/ui/input.tsx` | shadcn/ui Input |
---
## Known limitations / not implemented
- **JWT in `localStorage`** — XSS risk; migrate to `httpOnly` cookie when backend supports it
- **No toast / notification system** — errors shown inline; success is silent
- **No loading skeletons** — "Loading…" text only
- **No app permission UI** per group — groups exist but permission grants are not yet implemented
- **No app permission UI** — all apps visible to all authenticated users
- **No loading skeletons** — spinner only
- **Raw text not in DocumentOut** — slide-over shows a placeholder; full text requires direct backend API call
---
## Future work
- [x] UI component library: shadcn/ui + Tailwind CSS — installed and wired up
- [x] AppShell + Sidebar replacing inline Nav component
- [x] Light/dark theme context with OS preference detection
- [x] Generic plugin infrastructure: Extensions sidebar section, PluginSchemaForm, PluginSettingsPage
- [ ] Suggestion badges in DocumentsPage for `suggested_folder` / `suggested_filename` (confirm/reject buttons)
- [ ] Toast notification system (upload success, save feedback, errors)
- [x] SourcePanel with views + searchable category navigation
- [x] DocumentSlideOver replacing expand-in-row
- [x] Filter chip system
- [x] Multi-file upload with queue panel + drag-and-drop
- [x] Bulk actions bar (share, delete)
- [x] Document sharing UI (Sharing section + Shared with me view)
- [x] AI suggestion confirm/reject UI (folder + filename)
- [x] Groups admin UI
- [ ] Toast notification system
- [ ] Loading skeletons
- [ ] `POST /queue/jobs` integration — show AI processing queue status / progress per document
- [ ] Advanced filter: extracted data fields (vendor, due date, amount) — needs backend support
- [x] Groups admin UI — list, create, edit, delete, add/remove members
- [ ] App permissions UI per group (blocked on backend group_app_permissions)
- [ ] Document sharing UI (blocked on backend)
- [ ] Cmd+K global search (`CommandDialog`)
- [ ] Advanced filter: extracted data fields (needs backend support)
- [ ] `httpOnly` cookie auth (requires backend change)
- [ ] Bulk document operations (select multiple, bulk delete / bulk categorise)
- [ ] TanStack Virtual for category list > 200 items
+36
View File
@@ -111,6 +111,20 @@ export interface DocumentOut {
watch_path: string | null;
suggested_folder: string | null;
suggested_filename: string | null;
share_count: number;
}
export interface SharedDocumentOut extends DocumentOut {
shared_by_user_id: string;
shared_via_group_id: string;
}
export interface DocumentShareOut {
id: string;
document_id: string;
group_id: string;
shared_by_user_id: string;
created_at: string;
}
export interface DocumentPage {
@@ -142,6 +156,18 @@ export interface DocumentStatusOut {
export const listDocuments = (params: DocumentListParams = {}) =>
api.get<DocumentPage>("/documents", { params }).then((r) => r.data);
export const listSharedWithMe = (params: DocumentListParams = {}) =>
api.get<DocumentPage>("/documents/shared-with-me", { params }).then((r) => r.data);
export const getDocumentShares = (docId: string) =>
api.get<DocumentShareOut[]>(`/documents/${docId}/shares`).then((r) => r.data);
export const addDocumentShare = (docId: string, groupId: string) =>
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId }).then((r) => r.data);
export const removeDocumentShare = (docId: string, groupId: string) =>
api.delete(`/documents/${docId}/shares/${groupId}`);
export const getDocument = (id: string) =>
api.get<DocumentOut>(`/documents/${id}`).then((r) => r.data);
@@ -289,6 +315,16 @@ export const updateDocumentLimits = (max_pdf_mb: number) =>
export const getDocumentLimits = () =>
api.get<Record<string, unknown>>("/settings/documents/limits").then((r) => r.data);
// --- User groups (current user's own memberships) ---
export interface UserGroupOut {
id: string;
name: string;
description: string | null;
}
export const getMyGroups = () =>
api.get<UserGroupOut[]>("/users/me/groups").then((r) => r.data);
// --- Groups (admin only) ---
export interface GroupOut {
id: string;
+6
View File
@@ -1,13 +1,19 @@
import { useLocation } from "react-router-dom";
import Sidebar from "@/components/Sidebar";
import SourcePanel from "@/components/SourcePanel";
interface AppShellProps {
children: React.ReactNode;
}
export default function AppShell({ children }: AppShellProps) {
const location = useLocation();
const showSourcePanel = location.pathname === "/apps/documents";
return (
<div className="flex h-screen bg-background">
<Sidebar />
{showSourcePanel && <SourcePanel />}
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
);
@@ -0,0 +1,769 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
X, Download, Eye, RefreshCw, Trash2, Check, Pencil, Plus,
ChevronDown, ChevronRight, Users, UserMinus,
} from "lucide-react";
import {
DocumentOut, CategoryOut,
updateDocumentTitle, updateDocumentTags, updateDocumentType,
assignCategory, removeCategory, deleteDocument,
downloadDocument, viewDocument, reprocessDocument,
confirmFolderSuggestion, rejectFolderSuggestion,
confirmFilenameSuggestion, rejectFilenameSuggestion,
listCategories,
getDocumentShares, addDocumentShare, removeDocumentShare,
getMyGroups,
} from "@/api/client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface Props {
doc: DocumentOut | null;
isOwner: boolean; // false for "shared with me" view
onClose: () => void;
onDeleted: (id: string) => void;
}
const STATUS_COLORS: Record<string, string> = {
pending: "bg-orange-400",
processing: "bg-blue-400",
done: "bg-emerald-400",
failed: "bg-red-400",
};
const DOC_TYPES = ["invoice", "bill", "receipt", "order", "expense", "revenue", "unknown"];
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string | null) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
// ── Category combobox ─────────────────────────────────────────────────────────
function CategoryCombobox({
categories, assigned, onAssign,
}: { categories: CategoryOut[]; assigned: CategoryOut[]; onAssign: (id: string) => void }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const assignedIds = new Set(assigned.map((c) => c.id));
const unassigned = categories.filter((c) => !assignedIds.has(c.id));
const filtered = search
? unassigned.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: unassigned;
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
if (unassigned.length === 0) return null;
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 text-xs text-muted hover:text-foreground transition-colors border border-dashed border-border rounded px-2 py-0.5"
>
<Plus className="h-3 w-3" /> Add category
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-20 bg-surface border border-border rounded-lg shadow-lg w-52">
{categories.length > 5 && (
<div className="p-2 border-b border-border">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="h-7 text-xs"
autoFocus
/>
</div>
)}
<div className="max-h-48 overflow-y-auto py-1">
{filtered.map((cat) => (
<button
key={cat.id}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors"
onClick={() => { onAssign(cat.id); setOpen(false); setSearch(""); }}
>
{cat.name}
</button>
))}
{filtered.length === 0 && (
<p className="text-xs text-muted px-3 py-2">No categories</p>
)}
</div>
</div>
)}
</div>
);
}
// ── Group picker combobox ─────────────────────────────────────────────────────
function GroupCombobox({
groups, sharedGroupIds, onShare,
}: { groups: { id: string; name: string }[]; sharedGroupIds: Set<string>; onShare: (id: string) => void }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const ref = useRef<HTMLDivElement>(null);
const available = groups.filter((g) => !sharedGroupIds.has(g.id));
const filtered = search
? available.filter((g) => g.name.toLowerCase().includes(search.toLowerCase()))
: available;
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
if (available.length === 0) return null;
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 text-xs text-muted hover:text-foreground transition-colors border border-dashed border-border rounded px-2 py-0.5 mt-1"
>
<Users className="h-3 w-3" /> Share with a group
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-20 bg-surface border border-border rounded-lg shadow-lg w-52">
{groups.length > 5 && (
<div className="p-2 border-b border-border">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups…"
className="h-7 text-xs"
autoFocus
/>
</div>
)}
<div className="max-h-48 overflow-y-auto py-1">
{filtered.map((g) => (
<button
key={g.id}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors"
onClick={() => { onShare(g.id); setOpen(false); setSearch(""); }}
>
{g.name}
</button>
))}
{filtered.length === 0 && (
<p className="text-xs text-muted px-3 py-2">No groups available</p>
)}
</div>
</div>
)}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }: Props) {
const queryClient = useQueryClient();
const [rawOpen, setRawOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
const [titleValue, setTitleValue] = useState("");
const [editingType, setEditingType] = useState(false);
const [tagInput, setTagInput] = useState("");
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (doc) {
setTitleValue(doc.title ?? "");
setEditingTitle(false);
setEditingType(false);
setRawOpen(false);
setTagInput("");
}
}, [doc?.id]);
useEffect(() => {
if (editingTitle) titleInputRef.current?.focus();
}, [editingTitle]);
const { data: allCategories = [] } = useQuery({
queryKey: ["categories"],
queryFn: listCategories,
});
const { data: myGroups = [] } = useQuery({
queryKey: ["my-groups"],
queryFn: getMyGroups,
enabled: isOwner,
});
const { data: shares = [] } = useQuery({
queryKey: ["document-shares", doc?.id],
queryFn: () => getDocumentShares(doc!.id),
enabled: isOwner && !!doc,
});
const invalidateDoc = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["documents"] });
}, [queryClient]);
const titleMut = useMutation({
mutationFn: (title: string) => updateDocumentTitle(doc!.id, title),
onSuccess: () => { invalidateDoc(); setEditingTitle(false); },
});
const typeMut = useMutation({
mutationFn: (document_type: string) => updateDocumentType(doc!.id, document_type),
onSuccess: () => { invalidateDoc(); setEditingType(false); },
});
const addTagMut = useMutation({
mutationFn: (tags: string[]) => updateDocumentTags(doc!.id, tags),
onSuccess: invalidateDoc,
});
const assignCatMut = useMutation({
mutationFn: (catId: string) => assignCategory(doc!.id, catId),
onSuccess: invalidateDoc,
});
const removeCatMut = useMutation({
mutationFn: (catId: string) => removeCategory(doc!.id, catId),
onSuccess: invalidateDoc,
});
const reprocessMut = useMutation({
mutationFn: () => reprocessDocument(doc!.id),
onSuccess: invalidateDoc,
});
const deleteMut = useMutation({
mutationFn: () => deleteDocument(doc!.id),
onSuccess: () => { invalidateDoc(); onDeleted(doc!.id); },
});
const confirmFolderMut = useMutation({
mutationFn: () => confirmFolderSuggestion(doc!.id),
onSuccess: invalidateDoc,
});
const rejectFolderMut = useMutation({
mutationFn: () => rejectFolderSuggestion(doc!.id),
onSuccess: invalidateDoc,
});
const confirmFilenameMut = useMutation({
mutationFn: () => confirmFilenameSuggestion(doc!.id),
onSuccess: invalidateDoc,
});
const rejectFilenameMut = useMutation({
mutationFn: () => rejectFilenameSuggestion(doc!.id),
onSuccess: invalidateDoc,
});
const addShareMut = useMutation({
mutationFn: (groupId: string) => addDocumentShare(doc!.id, groupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["document-shares", doc!.id] }),
});
const removeShareMut = useMutation({
mutationFn: (groupId: string) => removeDocumentShare(doc!.id, groupId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["document-shares", doc!.id] }),
});
if (!doc) return null;
const extractedData = (() => {
try { return doc.extracted_data ? JSON.parse(doc.extracted_data) : null; }
catch { return null; }
})();
const tags: string[] = (() => {
try { return doc.tags ? JSON.parse(doc.tags) : []; }
catch { return []; }
})();
const suggestedCategories: string[] = (() => {
try { return extractedData?.suggested_categories ?? []; }
catch { return []; }
})();
const displayKeys = extractedData
? Object.entries(extractedData).filter(([k]) => !["tags", "suggested_categories"].includes(k))
: [];
const sharedGroupIds = new Set(shares.map((s) => s.group_id));
function addTag() {
const tag = tagInput.trim();
if (!tag || tags.includes(tag)) return;
addTagMut.mutate([...tags, tag]);
setTagInput("");
}
function removeTag(tag: string) {
addTagMut.mutate(tags.filter((t) => t !== tag));
}
return (
<div
className="fixed inset-0 z-40"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
{/* Overlay backdrop (subtle) */}
<div className="absolute inset-0 bg-background/20" />
{/* Panel */}
<div className="absolute inset-y-0 right-0 w-[480px] bg-surface border-l border-border shadow-2xl flex flex-col overflow-hidden z-50">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-border shrink-0">
<div className="flex-1 min-w-0">
<p className="text-xs text-muted truncate">{doc.filename}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{isOwner && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => viewDocument(doc.id)}
className="h-7 px-2 gap-1 text-xs"
>
<Eye className="h-3.5 w-3.5" /> View
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => downloadDocument(doc.id, doc.filename)}
className="h-7 px-2 gap-1 text-xs"
>
<Download className="h-3.5 w-3.5" /> Download
</Button>
</>
)}
{!isOwner && (
<>
<Button variant="ghost" size="sm" onClick={() => viewDocument(doc.id)} className="h-7 px-2 gap-1 text-xs">
<Eye className="h-3.5 w-3.5" /> View
</Button>
<Button variant="ghost" size="sm" onClick={() => downloadDocument(doc.id, doc.filename)} className="h-7 px-2 gap-1 text-xs">
<Download className="h-3.5 w-3.5" /> Download
</Button>
</>
)}
<button onClick={onClose} className="ml-1 text-muted hover:text-foreground transition-colors">
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Metadata row */}
<div className="flex items-center gap-3 flex-wrap text-xs text-muted">
<span className="flex items-center gap-1.5">
<span className={cn("h-2 w-2 rounded-full", STATUS_COLORS[doc.status] ?? "bg-gray-400")} />
{doc.status}
</span>
<span>{formatBytes(doc.file_size)}</span>
<span>Uploaded {formatDate(doc.created_at)}</span>
{doc.processed_at && <span>Processed {formatDate(doc.processed_at)}</span>}
{doc.source === "watch" && (
<span className="text-primary/60">Watch-ingested</span>
)}
</div>
{/* Error */}
{doc.status === "failed" && doc.error_message && (
<div className="text-xs text-red-500 bg-red-500/10 rounded-md px-3 py-2">
Error: {doc.error_message}
</div>
)}
{/* Title */}
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Title</p>
{isOwner && editingTitle ? (
<form
onSubmit={(e) => { e.preventDefault(); titleMut.mutate(titleValue); }}
className="flex gap-2"
>
<Input
ref={titleInputRef}
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
className="h-8 text-sm flex-1"
disabled={titleMut.isPending}
onKeyDown={(e) => { if (e.key === "Escape") setEditingTitle(false); }}
/>
<button type="submit" disabled={titleMut.isPending} className="text-primary disabled:opacity-50">
<Check className="h-4 w-4" />
</button>
<button type="button" onClick={() => setEditingTitle(false)} className="text-muted hover:text-foreground">
<X className="h-4 w-4" />
</button>
</form>
) : (
<div className="flex items-center gap-2 group">
<span className={cn("text-sm", !doc.title && "text-muted italic")}>
{doc.title ?? "No title"}
</span>
{isOwner && (
<button
onClick={() => { setTitleValue(doc.title ?? ""); setEditingTitle(true); }}
className="text-muted opacity-0 group-hover:opacity-100 hover:text-foreground transition-all"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
</div>
{/* Document type */}
{isOwner && (
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Type</p>
{editingType ? (
<div className="flex gap-1 flex-wrap">
{DOC_TYPES.map((t) => (
<button
key={t}
onClick={() => typeMut.mutate(t)}
disabled={typeMut.isPending}
className={cn(
"text-xs px-2 py-0.5 rounded border transition-colors",
doc.document_type === t
? "border-primary text-primary bg-primary/10"
: "border-border text-muted hover:border-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
<button onClick={() => setEditingType(false)} className="text-muted hover:text-foreground">
<X className="h-4 w-4" />
</button>
</div>
) : (
<div className="flex items-center gap-2 group">
<span className={cn("text-sm", !doc.document_type && "text-muted italic")}>
{doc.document_type ?? "Unknown"}
</span>
<button
onClick={() => setEditingType(true)}
className="text-muted opacity-0 group-hover:opacity-100 hover:text-foreground transition-all"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)}
{/* AI Suggestions */}
{isOwner && (doc.suggested_folder || doc.suggested_filename) && (
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">
AI Suggestions
</p>
<div className="space-y-2">
{doc.suggested_folder && (
<div className="flex items-center gap-2 text-sm bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2">
<span className="flex-1">
<span className="text-xs text-muted mr-1">Folder:</span>
{doc.suggested_folder}
</span>
<button
onClick={() => confirmFolderMut.mutate()}
disabled={confirmFolderMut.isPending || rejectFolderMut.isPending}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium disabled:opacity-50"
>
Apply
</button>
<button
onClick={() => rejectFolderMut.mutate()}
disabled={confirmFolderMut.isPending || rejectFolderMut.isPending}
className="text-muted hover:text-red-500 transition-colors disabled:opacity-50"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{doc.suggested_filename && (
<div className="flex items-center gap-2 text-sm bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2">
<span className="flex-1">
<span className="text-xs text-muted mr-1">Title:</span>
{doc.suggested_filename}
</span>
<button
onClick={() => confirmFilenameMut.mutate()}
disabled={confirmFilenameMut.isPending || rejectFilenameMut.isPending}
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium disabled:opacity-50"
>
Apply
</button>
<button
onClick={() => rejectFilenameMut.mutate()}
disabled={confirmFilenameMut.isPending || rejectFilenameMut.isPending}
className="text-muted hover:text-red-500 transition-colors disabled:opacity-50"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</div>
)}
{/* Extracted data */}
{displayKeys.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">
Extracted Data
</p>
<table className="w-full text-xs">
<tbody>
{displayKeys.map(([k, v]) => (
<tr key={k} className="border-b border-border/50 last:border-0">
<td className="py-1.5 pr-3 text-muted font-medium w-1/3 align-top">
{k.replace(/_/g, " ")}
</td>
<td className="py-1.5 text-foreground align-top break-words">
{v === null || v === undefined ? (
<span className="text-muted"></span>
) : Array.isArray(v) ? (
<span className="font-mono">{JSON.stringify(v)}</span>
) : (
String(v)
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Categories */}
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Categories</p>
<div className="flex flex-wrap gap-1.5">
{doc.categories.map((cat) => (
<span
key={cat.id}
className="flex items-center gap-1 text-xs bg-primary/10 text-primary rounded-full px-2.5 py-0.5"
>
{cat.name}
{isOwner && (
<button
onClick={() => removeCatMut.mutate(cat.id)}
disabled={removeCatMut.isPending}
className="hover:text-red-500 transition-colors disabled:opacity-50"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
{isOwner && (
<CategoryCombobox
categories={allCategories}
assigned={doc.categories}
onAssign={(id) => assignCatMut.mutate(id)}
/>
)}
</div>
{/* AI-suggested categories */}
{isOwner && suggestedCategories.length > 0 && (
<div className="mt-2">
<p className="text-xs text-muted mb-1">Suggested by AI:</p>
<div className="flex flex-wrap gap-1.5">
{suggestedCategories
.filter((name) => !doc.categories.some((c) => c.name.toLowerCase() === name.toLowerCase()))
.map((name) => {
const exists = allCategories.find(
(c) => c.name.toLowerCase() === name.toLowerCase()
);
return (
<span
key={name}
className="flex items-center gap-1 text-xs bg-amber-500/10 text-amber-700 dark:text-amber-400 rounded-full px-2.5 py-0.5 border border-amber-500/20"
>
{name}
{exists ? (
<button
onClick={() => assignCatMut.mutate(exists.id)}
className="text-emerald-600 hover:text-emerald-700"
title="Assign"
>
<Check className="h-3 w-3" />
</button>
) : (
<button
onClick={async () => {
// Create the category then assign it
const { createCategory } = await import("@/api/client");
const cat = await createCategory(name);
queryClient.invalidateQueries({ queryKey: ["categories"] });
assignCatMut.mutate(cat.id);
}}
className="text-emerald-600 hover:text-emerald-700 text-[10px] font-medium"
title="Create & assign"
>
+
</button>
)}
</span>
);
})}
</div>
</div>
)}
</div>
{/* Tags */}
{isOwner && (
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Tags</p>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 text-xs bg-muted/20 rounded-full px-2.5 py-0.5"
>
{tag}
<button onClick={() => removeTag(tag)} className="text-muted hover:text-red-500">
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<form
onSubmit={(e) => { e.preventDefault(); addTag(); }}
className="flex gap-2"
>
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="Add tag…"
className="h-7 text-xs flex-1"
disabled={addTagMut.isPending}
/>
<button
type="submit"
disabled={!tagInput.trim() || addTagMut.isPending}
className="text-primary disabled:opacity-50"
>
<Plus className="h-4 w-4" />
</button>
</form>
</div>
)}
{/* Sharing */}
{isOwner && (
<div>
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Sharing</p>
{shares.length === 0 && (
<p className="text-xs text-muted mb-1">Not shared with any groups</p>
)}
<div className="space-y-1 mb-1">
{shares.map((share) => {
const group = myGroups.find((g) => g.id === share.group_id);
return (
<div key={share.id} className="flex items-center gap-2 text-sm">
<Users className="h-3.5 w-3.5 text-muted shrink-0" />
<span className="flex-1 text-sm">{group?.name ?? share.group_id}</span>
<button
onClick={() => removeShareMut.mutate(share.group_id)}
disabled={removeShareMut.isPending}
className="text-muted hover:text-red-500 transition-colors disabled:opacity-50"
title="Stop sharing"
>
<UserMinus className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
<GroupCombobox
groups={myGroups}
sharedGroupIds={sharedGroupIds}
onShare={(id) => addShareMut.mutate(id)}
/>
</div>
)}
{/* Owner actions */}
{isOwner && (
<div className="flex gap-2 pt-1">
<Button
variant="outline"
size="sm"
onClick={() => reprocessMut.mutate()}
disabled={reprocessMut.isPending || doc.status === "pending" || doc.status === "processing"}
className="gap-1.5"
>
<RefreshCw className={cn("h-3.5 w-3.5", reprocessMut.isPending && "animate-spin")} />
Re-analyse
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
if (confirm(`Delete "${doc.title ?? doc.filename}"? This cannot be undone.`)) {
deleteMut.mutate();
}
}}
disabled={deleteMut.isPending}
className="gap-1.5 text-red-500 border-red-200 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
</div>
)}
{/* Raw text */}
{doc.source && (
<div>
<button
onClick={() => setRawOpen((o) => !o)}
className="flex items-center gap-1.5 text-xs text-muted hover:text-foreground transition-colors"
>
{rawOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
Extracted text
</button>
{rawOpen && (
<pre className="mt-2 text-xs bg-muted/10 border border-border rounded-md p-3 overflow-y-auto max-h-64 whitespace-pre-wrap break-words font-mono">
{/* raw_text not in DocumentOut — show message */}
(Raw text is stored server-side; use the backend API to retrieve it.)
</pre>
)}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,164 @@
import { useState, useRef, useEffect } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { X, Pencil, Trash2, Check } from "lucide-react";
import { listCategories, renameCategory, deleteCategory } from "@/api/client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface Props {
onClose: () => void;
}
export default function ManageCategoriesDialog({ onClose }: Props) {
const queryClient = useQueryClient();
const { data: categories = [] } = useQuery({
queryKey: ["categories"],
queryFn: listCategories,
});
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [search, setSearch] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingId) editInputRef.current?.focus();
}, [editingId]);
const renameMut = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => renameCategory(id, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setEditingId(null);
},
});
const deleteMut = useMutation({
mutationFn: deleteCategory,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
});
const filtered = search
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
: categories;
function startEdit(id: string, name: string) {
setEditingId(id);
setEditValue(name);
}
function submitEdit(id: string) {
const name = editValue.trim();
if (name && name !== categories.find((c) => c.id === id)?.name) {
renameMut.mutate({ id, name });
} else {
setEditingId(null);
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/70"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-surface border border-border rounded-lg w-[520px] max-h-[80vh] flex flex-col shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 className="text-base font-semibold">Manage Categories</h2>
<button onClick={onClose} className="text-muted hover:text-foreground transition-colors">
<X className="h-5 w-5" />
</button>
</div>
{/* Search */}
{categories.length > 6 && (
<div className="px-5 pt-4 pb-2">
<Input
placeholder="Search categories…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-sm"
/>
</div>
)}
{/* List */}
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-1 min-h-0">
{filtered.length === 0 && (
<p className="text-sm text-muted py-4 text-center">
{search ? "No categories match" : "No categories yet"}
</p>
)}
{filtered.map((cat) => (
<div
key={cat.id}
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/10 group"
>
{editingId === cat.id ? (
<form
className="flex items-center gap-2 flex-1"
onSubmit={(e) => { e.preventDefault(); submitEdit(cat.id); }}
>
<Input
ref={editInputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
className="h-7 text-sm flex-1"
disabled={renameMut.isPending}
onKeyDown={(e) => { if (e.key === "Escape") setEditingId(null); }}
/>
<button
type="submit"
disabled={!editValue.trim() || renameMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</form>
) : (
<>
<span className="flex-1 text-sm truncate">{cat.name}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(cat.id, cat.name)}
className="text-muted hover:text-foreground transition-colors p-0.5"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Delete category "${cat.name}"? Documents in it will be uncategorised.`)) {
deleteMut.mutate(cat.id);
}
}}
disabled={deleteMut.isPending}
className="text-muted hover:text-red-500 transition-colors p-0.5 disabled:opacity-50"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</>
)}
</div>
))}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-border">
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}
+8 -70
View File
@@ -12,7 +12,6 @@ import {
LogOut,
UserCircle,
FileText,
Folder,
Users,
UsersRound,
Palette,
@@ -20,7 +19,7 @@ import {
import { Button } from "@/components/ui/button";
import ThemeToggle from "@/components/ThemeToggle";
import { useAuth } from "@/hooks/useAuth";
import { getMe, listCategories } from "@/api/client";
import { getMe } from "@/api/client";
import { cn } from "@/lib/utils";
export default function Sidebar() {
@@ -30,11 +29,9 @@ export default function Sidebar() {
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
const isAppsRoute = location.pathname.startsWith("/apps");
const isDocsRoute = location.pathname.startsWith("/apps/documents");
const isAdminRoute = location.pathname.startsWith("/admin");
const [appsOpen, setAppsOpen] = useState(isAppsRoute);
const [docsOpen, setDocsOpen] = useState(isDocsRoute);
const [adminOpen, setAdminOpen] = useState(isAdminRoute);
// Auto-open sections when navigating to their routes
@@ -42,20 +39,10 @@ export default function Sidebar() {
if (isAppsRoute) setAppsOpen(true);
}, [isAppsRoute]);
useEffect(() => {
if (isDocsRoute) setDocsOpen(true);
}, [isDocsRoute]);
useEffect(() => {
if (isAdminRoute) setAdminOpen(true);
}, [isAdminRoute]);
const { data: categories = [] } = useQuery({
queryKey: ["categories"],
queryFn: listCategories,
enabled: appsOpen && docsOpen && !!user,
});
const navItemClass = (isActive: boolean) =>
cn(
"flex items-center rounded-lg transition-colors",
@@ -74,15 +61,6 @@ export default function Sidebar() {
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
const subSubItemClass = (isActive: boolean) =>
cn(
"flex items-center rounded-lg transition-colors text-sm",
"pl-12 pr-3 py-1 gap-2",
isActive
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
return (
<aside
className={cn(
@@ -147,53 +125,13 @@ export default function Sidebar() {
{/* Apps sub-items — only when sidebar is expanded and appsOpen */}
{sidebarExpanded && appsOpen && (
<div className="mt-0.5 space-y-0.5">
{/* Documents service */}
<div>
<div
className={cn(
"flex items-center rounded-lg transition-colors text-sm",
isDocsRoute
? "bg-primary/10 text-primary"
: "text-muted hover:bg-muted/20 hover:text-foreground"
)}
>
<NavLink
to="/apps/documents"
end
className="flex items-center gap-2 pl-8 pr-2 py-1.5 flex-1 min-w-0"
>
<FileText className="h-4 w-4 shrink-0" />
<span className="whitespace-nowrap">Documents</span>
</NavLink>
<button
onClick={() => setDocsOpen((o) => !o)}
className="px-2 py-1.5 rounded-r-lg"
aria-label={docsOpen ? "Collapse documents" : "Expand documents"}
>
{docsOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
</button>
</div>
{/* Categories */}
{docsOpen && (
<div className="mt-0.5 space-y-0.5">
{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>
<NavLink
to="/apps/documents"
className={({ isActive }) => subItemClass(isActive)}
>
<FileText className="h-4 w-4 shrink-0" />
<span className="whitespace-nowrap">Documents</span>
</NavLink>
</div>
)}
</div>
+205
View File
@@ -0,0 +1,205 @@
import { useState, useRef, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Files, User, Users, Folder, Plus, Settings2, Check, X } from "lucide-react";
import { listCategories, createCategory, CategoryOut } from "@/api/client";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import ManageCategoriesDialog from "@/components/ManageCategoriesDialog";
export default function SourcePanel() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const currentView = searchParams.get("view") ?? "all";
const currentCategoryId = searchParams.get("category_id");
const [catSearch, setCatSearch] = useState("");
const [addingCat, setAddingCat] = useState(false);
const [newCatName, setNewCatName] = useState("");
const [manageOpen, setManageOpen] = useState(false);
const addInputRef = useRef<HTMLInputElement>(null);
const { data: categories = [] } = useQuery({
queryKey: ["categories"],
queryFn: listCategories,
});
const createMut = useMutation({
mutationFn: createCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setNewCatName("");
setAddingCat(false);
},
});
useEffect(() => {
if (addingCat) addInputRef.current?.focus();
}, [addingCat]);
const filteredCats = catSearch
? categories.filter((c) => c.name.toLowerCase().includes(catSearch.toLowerCase()))
: categories;
function setView(view: string) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("view", view);
next.delete("category_id");
next.set("page", "1");
return next;
});
}
function selectCategory(cat: CategoryOut) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("view");
next.set("category_id", cat.id);
next.set("page", "1");
return next;
});
}
const viewItemClass = (active: boolean) =>
cn(
"flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors",
active
? "bg-primary/10 text-primary font-medium"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
const catItemClass = (active: boolean) =>
cn(
"flex items-center gap-2 w-full px-2 py-1 rounded-md text-sm transition-colors",
active
? "bg-primary/10 text-primary font-medium"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
return (
<>
<aside className="w-56 flex flex-col border-r border-border bg-surface shrink-0 h-screen">
{/* Views */}
<div className="p-3 border-b border-border">
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5 px-1">
Views
</p>
<button
className={viewItemClass(!currentCategoryId && (currentView === "all" || !currentView))}
onClick={() => setView("all")}
>
<Files className="h-4 w-4 shrink-0" />
All Documents
</button>
<button
className={viewItemClass(!currentCategoryId && currentView === "mine")}
onClick={() => setView("mine")}
>
<User className="h-4 w-4 shrink-0" />
Mine
</button>
<button
className={viewItemClass(!currentCategoryId && currentView === "shared")}
onClick={() => setView("shared")}
>
<Users className="h-4 w-4 shrink-0" />
Shared with me
</button>
</div>
{/* Categories */}
<div className="flex-1 flex flex-col min-h-0 p-3">
<div className="flex items-center justify-between mb-1.5 px-1">
<p className="text-xs font-semibold text-muted uppercase tracking-wider">
Categories
</p>
<button
onClick={() => setManageOpen(true)}
className="text-muted hover:text-foreground transition-colors"
title="Manage categories"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
{categories.length > 4 && (
<Input
placeholder="Search…"
value={catSearch}
onChange={(e) => setCatSearch(e.target.value)}
className="h-7 text-xs mb-2"
/>
)}
<div className="flex-1 overflow-y-auto space-y-0.5 min-h-0">
{filteredCats.map((cat) => (
<button
key={cat.id}
className={catItemClass(currentCategoryId === cat.id)}
onClick={() => selectCategory(cat)}
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cat.name}</span>
</button>
))}
{filteredCats.length === 0 && catSearch && (
<p className="text-xs text-muted px-2 py-1">No categories match</p>
)}
{categories.length === 0 && !catSearch && (
<p className="text-xs text-muted px-2 py-1">No categories yet</p>
)}
</div>
{/* Add new category */}
<div className="pt-2 border-t border-border mt-2">
{addingCat ? (
<form
onSubmit={(e) => {
e.preventDefault();
if (newCatName.trim()) createMut.mutate(newCatName.trim());
}}
className="flex items-center gap-1"
>
<Input
ref={addInputRef}
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
placeholder="Category name"
className="h-7 text-xs flex-1"
disabled={createMut.isPending}
/>
<button
type="submit"
disabled={!newCatName.trim() || createMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => { setAddingCat(false); setNewCatName(""); }}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</form>
) : (
<button
onClick={() => setAddingCat(true)}
className="flex items-center gap-1.5 text-xs text-muted hover:text-foreground transition-colors w-full px-2 py-1"
>
<Plus className="h-3.5 w-3.5" />
New category
</button>
)}
</div>
</div>
</aside>
{manageOpen && (
<ManageCategoriesDialog onClose={() => setManageOpen(false)} />
)}
</>
);
}
File diff suppressed because it is too large Load Diff