diff --git a/CLAUDE.md b/CLAUDE.md index 59604dd..8d491ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/backend/STATUS.md b/backend/STATUS.md index ada52db..963a870 100644 --- a/backend/STATUS.md +++ b/backend/STATUS.md @@ -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`) diff --git a/backend/app/routers/categories_proxy.py b/backend/app/routers/categories_proxy.py index 562df77..7d6936b 100644 --- a/backend/app/routers/categories_proxy.py +++ b/backend/app/routers/categories_proxy.py @@ -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: diff --git a/backend/app/routers/documents_proxy.py b/backend/app/routers/documents_proxy.py index 4c5b359..55bcc61 100644 --- a/backend/app/routers/documents_proxy.py +++ b/backend/app/routers/documents_proxy.py @@ -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: diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 6176c12..9974f7a 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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, diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index a880e48..adddec7 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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) diff --git a/changelog/2026-04-18_doc-service-redesign.md b/changelog/2026-04-18_doc-service-redesign.md new file mode 100644 index 0000000..3330921 --- /dev/null +++ b/changelog/2026-04-18_doc-service-redesign.md @@ -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 | diff --git a/features/doc-service/STATUS.md b/features/doc-service/STATUS.md index 3c4322e..b503fe7 100644 --- a/features/doc-service/STATUS.md +++ b/features/doc-service/STATUS.md @@ -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: ,,...` 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) diff --git a/features/doc-service/alembic/versions/0004_add_document_shares.py b/features/doc-service/alembic/versions/0004_add_document_shares.py new file mode 100644 index 0000000..deb7abf --- /dev/null +++ b/features/doc-service/alembic/versions/0004_add_document_shares.py @@ -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") diff --git a/features/doc-service/app/deps.py b/features/doc-service/app/deps.py index 9ca9600..21adc31 100644 --- a/features/doc-service/app/deps.py +++ b/features/doc-service/app/deps.py @@ -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()] diff --git a/features/doc-service/app/models/__init__.py b/features/doc-service/app/models/__init__.py index b8c5e5b..f53f54d 100644 --- a/features/doc-service/app/models/__init__.py +++ b/features/doc-service/app/models/__init__.py @@ -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"] diff --git a/features/doc-service/app/models/document_share.py b/features/doc-service/app/models/document_share.py new file mode 100644 index 0000000..45822b3 --- /dev/null +++ b/features/doc-service/app/models/document_share.py @@ -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"), + ) diff --git a/features/doc-service/app/routers/documents.py b/features/doc-service/app/routers/documents.py index 3989ea4..b68a736 100644 --- a/features/doc-service/app/routers/documents.py +++ b/features/doc-service/app/routers/documents.py @@ -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() diff --git a/features/doc-service/app/schemas/document.py b/features/doc-service/app/schemas/document.py index 861ad64..4cba912 100644 --- a/features/doc-service/app/schemas/document.py +++ b/features/doc-service/app/schemas/document.py @@ -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} diff --git a/features/doc-service/app/schemas/share.py b/features/doc-service/app/schemas/share.py new file mode 100644 index 0000000..5baff2f --- /dev/null +++ b/features/doc-service/app/schemas/share.py @@ -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} diff --git a/frontend/STATUS.md b/frontend/STATUS.md index a6a5e3d..734a471 100644 --- a/frontend/STATUS.md +++ b/frontend/STATUS.md @@ -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=` -- 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 "X–Y 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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3cd31c5..47aaf97 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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("/documents", { params }).then((r) => r.data); +export const listSharedWithMe = (params: DocumentListParams = {}) => + api.get("/documents/shared-with-me", { params }).then((r) => r.data); + +export const getDocumentShares = (docId: string) => + api.get(`/documents/${docId}/shares`).then((r) => r.data); + +export const addDocumentShare = (docId: string, groupId: string) => + api.post(`/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(`/documents/${id}`).then((r) => r.data); @@ -289,6 +315,16 @@ export const updateDocumentLimits = (max_pdf_mb: number) => export const getDocumentLimits = () => api.get>("/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("/users/me/groups").then((r) => r.data); + // --- Groups (admin only) --- export interface GroupOut { id: string; diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 3629436..094f291 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -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 (
+ {showSourcePanel && }
{children}
); diff --git a/frontend/src/components/DocumentSlideOver.tsx b/frontend/src/components/DocumentSlideOver.tsx new file mode 100644 index 0000000..387461f --- /dev/null +++ b/frontend/src/components/DocumentSlideOver.tsx @@ -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 = { + 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(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 ( +
+ + {open && ( +
+ {categories.length > 5 && ( +
+ setSearch(e.target.value)} + placeholder="Search…" + className="h-7 text-xs" + autoFocus + /> +
+ )} +
+ {filtered.map((cat) => ( + + ))} + {filtered.length === 0 && ( +

No categories

+ )} +
+
+ )} +
+ ); +} + +// ── Group picker combobox ───────────────────────────────────────────────────── + +function GroupCombobox({ + groups, sharedGroupIds, onShare, +}: { groups: { id: string; name: string }[]; sharedGroupIds: Set; onShare: (id: string) => void }) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const ref = useRef(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 ( +
+ + {open && ( +
+ {groups.length > 5 && ( +
+ setSearch(e.target.value)} + placeholder="Search groups…" + className="h-7 text-xs" + autoFocus + /> +
+ )} +
+ {filtered.map((g) => ( + + ))} + {filtered.length === 0 && ( +

No groups available

+ )} +
+
+ )} +
+ ); +} + +// ── 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(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 ( +
{ if (e.target === e.currentTarget) onClose(); }} + > + {/* Overlay backdrop (subtle) */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+

{doc.filename}

+
+
+ {isOwner && ( + <> + + + + )} + {!isOwner && ( + <> + + + + )} + +
+
+ + {/* Scrollable body */} +
+ + {/* Metadata row */} +
+ + + {doc.status} + + {formatBytes(doc.file_size)} + Uploaded {formatDate(doc.created_at)} + {doc.processed_at && Processed {formatDate(doc.processed_at)}} + {doc.source === "watch" && ( + Watch-ingested + )} +
+ + {/* Error */} + {doc.status === "failed" && doc.error_message && ( +
+ Error: {doc.error_message} +
+ )} + + {/* Title */} +
+

Title

+ {isOwner && editingTitle ? ( +
{ e.preventDefault(); titleMut.mutate(titleValue); }} + className="flex gap-2" + > + setTitleValue(e.target.value)} + className="h-8 text-sm flex-1" + disabled={titleMut.isPending} + onKeyDown={(e) => { if (e.key === "Escape") setEditingTitle(false); }} + /> + + +
+ ) : ( +
+ + {doc.title ?? "No title"} + + {isOwner && ( + + )} +
+ )} +
+ + {/* Document type */} + {isOwner && ( +
+

Type

+ {editingType ? ( +
+ {DOC_TYPES.map((t) => ( + + ))} + +
+ ) : ( +
+ + {doc.document_type ?? "Unknown"} + + +
+ )} +
+ )} + + {/* AI Suggestions */} + {isOwner && (doc.suggested_folder || doc.suggested_filename) && ( +
+

+ AI Suggestions +

+
+ {doc.suggested_folder && ( +
+ + Folder: + {doc.suggested_folder} + + + +
+ )} + {doc.suggested_filename && ( +
+ + Title: + {doc.suggested_filename} + + + +
+ )} +
+
+ )} + + {/* Extracted data */} + {displayKeys.length > 0 && ( +
+

+ Extracted Data +

+ + + {displayKeys.map(([k, v]) => ( + + + + + ))} + +
+ {k.replace(/_/g, " ")} + + {v === null || v === undefined ? ( + + ) : Array.isArray(v) ? ( + {JSON.stringify(v)} + ) : ( + String(v) + )} +
+
+ )} + + {/* Categories */} +
+

Categories

+
+ {doc.categories.map((cat) => ( + + {cat.name} + {isOwner && ( + + )} + + ))} + {isOwner && ( + assignCatMut.mutate(id)} + /> + )} +
+ + {/* AI-suggested categories */} + {isOwner && suggestedCategories.length > 0 && ( +
+

Suggested by AI:

+
+ {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 ( + + {name} + {exists ? ( + + ) : ( + + )} + + ); + })} +
+
+ )} +
+ + {/* Tags */} + {isOwner && ( +
+

Tags

+
+ {tags.map((tag) => ( + + {tag} + + + ))} +
+
{ e.preventDefault(); addTag(); }} + className="flex gap-2" + > + setTagInput(e.target.value)} + placeholder="Add tag…" + className="h-7 text-xs flex-1" + disabled={addTagMut.isPending} + /> + +
+
+ )} + + {/* Sharing */} + {isOwner && ( +
+

Sharing

+ {shares.length === 0 && ( +

Not shared with any groups

+ )} +
+ {shares.map((share) => { + const group = myGroups.find((g) => g.id === share.group_id); + return ( +
+ + {group?.name ?? share.group_id} + +
+ ); + })} +
+ addShareMut.mutate(id)} + /> +
+ )} + + {/* Owner actions */} + {isOwner && ( +
+ + +
+ )} + + {/* Raw text */} + {doc.source && ( +
+ + {rawOpen && ( +
+                  {/* raw_text not in DocumentOut — show message */}
+                  (Raw text is stored server-side; use the backend API to retrieve it.)
+                
+ )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/ManageCategoriesDialog.tsx b/frontend/src/components/ManageCategoriesDialog.tsx new file mode 100644 index 0000000..c832ed8 --- /dev/null +++ b/frontend/src/components/ManageCategoriesDialog.tsx @@ -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(null); + const [editValue, setEditValue] = useState(""); + const [search, setSearch] = useState(""); + const editInputRef = useRef(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 ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ {/* Header */} +
+

Manage Categories

+ +
+ + {/* Search */} + {categories.length > 6 && ( +
+ setSearch(e.target.value)} + className="h-8 text-sm" + /> +
+ )} + + {/* List */} +
+ {filtered.length === 0 && ( +

+ {search ? "No categories match" : "No categories yet"} +

+ )} + {filtered.map((cat) => ( +
+ {editingId === cat.id ? ( +
{ e.preventDefault(); submitEdit(cat.id); }} + > + setEditValue(e.target.value)} + className="h-7 text-sm flex-1" + disabled={renameMut.isPending} + onKeyDown={(e) => { if (e.key === "Escape") setEditingId(null); }} + /> + + +
+ ) : ( + <> + {cat.name} +
+ + +
+ + )} +
+ ))} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 7cdb440..43d7a5c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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 (
)} diff --git a/frontend/src/components/SourcePanel.tsx b/frontend/src/components/SourcePanel.tsx new file mode 100644 index 0000000..d0fa7e7 --- /dev/null +++ b/frontend/src/components/SourcePanel.tsx @@ -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(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 ( + <> + + + {manageOpen && ( + setManageOpen(false)} /> + )} + + ); +} diff --git a/frontend/src/pages/DocumentsPage.tsx b/frontend/src/pages/DocumentsPage.tsx index 1443d27..28d4181 100644 --- a/frontend/src/pages/DocumentsPage.tsx +++ b/frontend/src/pages/DocumentsPage.tsx @@ -1,403 +1,98 @@ -import { useRef, useState, useEffect, useCallback } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { useSearchParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { - listDocuments, - uploadDocument, - deleteDocument, - downloadDocument, - viewDocument, - getDocumentStatus, - listCategories, - createCategory, - assignCategory, - removeCategory, - updateDocumentTitle, - reprocessDocument, - type DocumentOut, - type CategoryOut, - type DocumentListParams, -} from "../api/client"; + Upload, Search, X, Plus, ChevronUp, ChevronDown, Users, + CheckSquare, Square, MoreHorizontal, FileText, Loader2, +} from "lucide-react"; +import { + listDocuments, listSharedWithMe, deleteDocument, + addDocumentShare, listCategories, getMyGroups, + DocumentOut, DocumentListParams, uploadDocument, +} from "@/api/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import DocumentSlideOver from "@/components/DocumentSlideOver"; +import { cn } from "@/lib/utils"; -function StatusBadge({ status }: { status: DocumentOut["status"] }) { - const colors: Record = { - pending: "#f4a261", - processing: "#2196f3", - done: "#2a9d8f", - failed: "#e63946", - }; - return ( - - {status} - - ); -} +// ── Types ───────────────────────────────────────────────────────────────────── -// ── Category suggestions ──────────────────────────────────────────────────── - -interface SuggestionChipProps { - name: string; - existing: CategoryOut | undefined; - onAccept: (name: string, existing: CategoryOut | undefined) => void; - onDismiss: (name: string) => void; -} - -function SuggestionChip({ name, existing, onAccept, onDismiss }: SuggestionChipProps) { - return ( - - {name} - - - - ); -} - -// ── Inline title editor ───────────────────────────────────────────────────── - -function InlineTitleEditor({ - docId, - currentTitle, - filename, - onSaved, -}: { - docId: string; - currentTitle: string | null; +interface UploadItem { + localId: string; filename: string; - onSaved: () => void; -}) { - const [editing, setEditing] = useState(false); - const [value, setValue] = useState(currentTitle ?? ""); - - const saveMut = useMutation({ - mutationFn: (t: string) => updateDocumentTitle(docId, t), - onSuccess: () => { onSaved(); setEditing(false); }, - }); - - if (!editing) { - return ( - - - {currentTitle ?? {filename}} - - - - ); - } - - return ( - e.stopPropagation()} style={{ display: "inline-flex", alignItems: "center", gap: 6, flex: 1 }}> - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") saveMut.mutate(value); - if (e.key === "Escape") { setEditing(false); setValue(currentTitle ?? ""); } - }} - style={{ fontSize: 14, padding: "2px 6px", border: "1px solid #888", borderRadius: 3, width: 280 }} - autoFocus - /> - - - - ); + status: "uploading" | "done" | "error"; + error?: string; + docId?: string; } -// ── Document row ──────────────────────────────────────────────────────────── +const STATUS_DOT: Record = { + pending: "bg-orange-400", + processing: "bg-blue-400", + done: "bg-emerald-400", + failed: "bg-red-400", +}; -function DocumentRow({ - doc, - categories, - onDelete, -}: { - doc: DocumentOut; - categories: CategoryOut[]; - onDelete: (id: string) => void; -}) { - const [expanded, setExpanded] = useState(false); - const qc = useQueryClient(); +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} - let extractedData: Record | null = null; - if (doc.extracted_data) { - try { extractedData = JSON.parse(doc.extracted_data); } catch { /* ignore */ } - } - - // Suggested categories from AI — dismissed ones tracked locally - const allSuggestions: string[] = Array.isArray(extractedData?.suggested_categories) - ? (extractedData!.suggested_categories as string[]) - : []; - const assignedNames = new Set(doc.categories.map((c) => c.name)); - const [dismissed, setDismissed] = useState>(new Set()); - - const pendingSuggestions = allSuggestions.filter( - (s) => !assignedNames.has(s) && !dismissed.has(s) - ); - - // Poll status while pending/processing - const { data: liveStatus } = useQuery({ - queryKey: ["docStatus", doc.id], - queryFn: () => getDocumentStatus(doc.id), - refetchInterval: (query) => { - const s = query.state.data?.status; - return s === "pending" || s === "processing" ? 3000 : false; - }, - enabled: doc.status === "pending" || doc.status === "processing", +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", month: "short", day: "numeric", }); +} + +// ── Row actions dropdown ────────────────────────────────────────────────────── + +function RowActionsMenu({ + doc, isOwner, onSelect, +}: { doc: DocumentOut; isOwner: boolean; onSelect: () => void }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const queryClient = useQueryClient(); useEffect(() => { - if (liveStatus?.status === "done" || liveStatus?.status === "failed") { - qc.invalidateQueries({ queryKey: ["documents"] }); - } - }, [liveStatus?.status, qc]); + 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); + }, []); - const assignMut = useMutation({ - mutationFn: ({ catId }: { catId: string }) => assignCategory(doc.id, catId), - onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }), + const deleteMut = useMutation({ + mutationFn: () => deleteDocument(doc.id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["documents"] }), }); - const removeCatMut = useMutation({ - mutationFn: ({ catId }: { catId: string }) => removeCategory(doc.id, catId), - onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }), - }); - - const createAndAssignMut = useMutation({ - mutationFn: async (name: string) => { - const cat = await createCategory(name); - await assignCategory(doc.id, cat.id); - return cat; - }, - onSuccess: (_cat, name) => { - setDismissed((prev) => new Set([...prev, name])); - qc.invalidateQueries({ queryKey: ["documents"] }); - qc.invalidateQueries({ queryKey: ["categories"] }); - }, - }); - - const reprocessMut = useMutation({ - mutationFn: () => reprocessDocument(doc.id), - onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }), - }); - - const handleAcceptSuggestion = (name: string, existing: CategoryOut | undefined) => { - if (existing) { - assignMut.mutate({ catId: existing.id }); - setDismissed((prev) => new Set([...prev, name])); - } else { - createAndAssignMut.mutate(name); - } - }; - - const assignedIds = new Set(doc.categories.map((c) => c.id)); - const unassigned = categories.filter((c) => !assignedIds.has(c.id)); - return ( -
- {/* Row header */} -
setExpanded((e) => !e)} +
e.stopPropagation()}> + - - - -
- - {/* Expanded detail */} - {expanded && ( -
- - {/* Extracted fields (excluding internal-only keys) */} - {extractedData && ( -
- Extracted data: - - - {Object.entries(extractedData) - .filter(([k]) => k !== "tags" && k !== "suggested_categories") - .map(([k, v]) => ( - - - - - ))} - -
{k} - {Array.isArray(v) - ? v.length === 0 ? "—" : JSON.stringify(v, null, 2) - : v !== null && v !== undefined && v !== "" ? String(v) : "—"} -
-
- )} - - {/* Error */} - {doc.error_message && ( -
- Error: {doc.error_message} -
- )} - - {/* Assigned categories */} -
- Categories:{" "} - {doc.categories.map((c) => ( - - {c.name}{" "} - - - ))} - {unassigned.length > 0 && ( - - )} -
- - {/* AI-suggested categories — user must confirm each one */} - {pendingSuggestions.length > 0 && ( -
- Suggested by AI: -
- {pendingSuggestions.map((name) => { - const existing = categories.find( - (c) => c.name.toLowerCase() === name.toLowerCase() - ); - return ( - setDismissed((prev) => new Set([...prev, n]))} - /> - ); - })} -
-

- "Assign" links an existing category · "Create & Assign" creates it first · ✕ dismisses the suggestion -

-
+ + + {open && ( +
+ + {isOwner && ( + )}
)} @@ -405,351 +100,754 @@ function DocumentRow({ ); } -// ── Filter bar ────────────────────────────────────────────────────────────── +// ── Bulk action bar ─────────────────────────────────────────────────────────── -const SORT_OPTIONS = [ - { value: "created_at", label: "Upload date" }, - { value: "processed_at", label: "Processed date" }, - { value: "title", label: "Title" }, - { value: "filename", label: "Filename" }, - { value: "file_size", label: "File size" }, - { value: "document_type", label: "Type" }, - { value: "status", label: "Status" }, -]; - -const STATUS_OPTIONS = ["pending", "processing", "done", "failed"]; -const TYPE_OPTIONS = ["invoice", "bill", "receipt", "order", "expense", "revenue", "unknown"]; - -function FilterBar({ - params, - activeCategory, - onChange, - onClearCategory, -}: { - params: DocumentListParams; - activeCategory: CategoryOut | undefined; - onChange: (p: Partial) => void; - onClearCategory: () => void; -}) { - const [searchInput, setSearchInput] = useState(params.search ?? ""); +function BulkActionBar({ + selectedIds, onClear, +}: { selectedIds: Set; onClear: () => void }) { + const [groupPickerOpen, setGroupPickerOpen] = useState(false); + const [groupSearch, setGroupSearch] = useState(""); + const ref = useRef(null); + const queryClient = useQueryClient(); + const { data: myGroups = [] } = useQuery({ queryKey: ["my-groups"], queryFn: getMyGroups }); useEffect(() => { - const id = setTimeout(() => onChange({ search: searchInput || undefined, page: 1 }), 400); - return () => clearTimeout(id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchInput]); + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setGroupPickerOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); - const anyFilterActive = !!(params.search || params.status || params.document_type || params.category_id); + const filteredGroups = groupSearch + ? myGroups.filter((g) => g.name.toLowerCase().includes(groupSearch.toLowerCase())) + : myGroups; + + async function shareAll(groupId: string) { + await Promise.all([...selectedIds].map((docId) => addDocumentShare(docId, groupId))); + queryClient.invalidateQueries({ queryKey: ["document-shares"] }); + setGroupPickerOpen(false); + onClear(); + } + + const deleteMut = useMutation({ + mutationFn: async () => { + await Promise.all([...selectedIds].map((id) => deleteDocument(id))); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["documents"] }); + onClear(); + }, + }); return ( -
- {activeCategory && ( - - Category: {activeCategory.name} - - - )} +
+ + {selectedIds.size} selected + - setSearchInput(e.target.value)} - placeholder="Search title, filename…" - style={{ padding: "6px 10px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4, width: 220 }} - /> +
+ + {groupPickerOpen && ( +
+ {myGroups.length > 4 && ( +
+ setGroupSearch(e.target.value)} + placeholder="Search groups…" + className="h-7 text-xs" + autoFocus + /> +
+ )} +
+ {filteredGroups.map((g) => ( + + ))} + {filteredGroups.length === 0 && ( +

No groups

+ )} +
+
+ )} +
- - - - - + Delete + +
+ ); +} - {anyFilterActive && ( +// ── Filter chips ────────────────────────────────────────────────────────────── + +type FilterKey = "status" | "document_type" | "category_id"; + +const FILTER_STATUS_OPTS = ["pending", "processing", "done", "failed"]; +const FILTER_TYPE_OPTS = ["invoice", "bill", "receipt", "order", "expense", "revenue", "unknown"]; + +function FilterChips({ + searchParams, setSearchParams, +}: { searchParams: URLSearchParams; setSearchParams: (fn: (p: URLSearchParams) => URLSearchParams) => void }) { + const [pickerOpen, setPickerOpen] = useState(false); + const [pickerStep, setPickerStep] = useState(null); + const ref = useRef(null); + const { data: categories = [] } = useQuery({ queryKey: ["categories"], queryFn: listCategories }); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setPickerOpen(false); + setPickerStep(null); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + function removeFilter(key: FilterKey) { + setSearchParams((p) => { const n = new URLSearchParams(p); n.delete(key); n.set("page", "1"); return n; }); + } + + function setFilter(key: FilterKey, value: string) { + setSearchParams((p) => { const n = new URLSearchParams(p); n.set(key, value); n.set("page", "1"); return n; }); + setPickerOpen(false); + setPickerStep(null); + } + + const activeStatus = searchParams.get("status"); + const activeType = searchParams.get("document_type"); + const activeCatId = searchParams.get("category_id"); + const activeCatName = categories.find((c) => c.id === activeCatId)?.name; + const hasActiveFilters = !!(activeStatus || activeType || activeCatId); + + return ( +
+ {activeStatus && ( + + Status: {activeStatus} + + + )} + {activeType && ( + + Type: {activeType} + + + )} + {activeCatId && ( + + Category: {activeCatName ?? activeCatId} + + + )} + {hasActiveFilters && ( )} + +
+ + {pickerOpen && ( +
+ {!pickerStep ? ( + <> + {!activeStatus && ( + + )} + {!activeType && ( + + )} + {!activeCatId && categories.length > 0 && ( + + )} + + ) : pickerStep === "status" ? ( + FILTER_STATUS_OPTS.map((s) => ( + + )) + ) : pickerStep === "document_type" ? ( + FILTER_TYPE_OPTS.map((t) => ( + + )) + ) : ( +
+ {categories.map((c) => ( + + ))} +
+ )} +
+ )} +
); } -// ── Pagination controls ────────────────────────────────────────────────────── +// ── Upload queue panel ──────────────────────────────────────────────────────── -function Pagination({ - page, - pages, - total, - perPage, - onChange, -}: { - page: number; - pages: number; - total: number; - perPage: number; - onChange: (p: number) => void; -}) { - const start = (page - 1) * perPage + 1; - const end = Math.min(page * perPage, total); +function UploadQueuePanel({ items, onOpenDoc }: { items: UploadItem[]; onOpenDoc: (id: string) => void }) { + const [collapsed, setCollapsed] = useState(false); + if (items.length === 0) return null; + + const pending = items.filter((i) => i.status === "uploading").length; return ( -
- - - {start}–{end} of {total} - - - Page {page} / {pages} + + {pending > 0 ? `Uploading ${pending} file${pending > 1 ? "s" : ""}…` : "Uploads complete"} + + {collapsed ? : } +
+ {!collapsed && ( +
+ {items.map((item) => ( +
+
+

{item.filename}

+ {item.error &&

{item.error}

} +
+ {item.status === "uploading" && ( + + )} + {item.status === "done" && ( + + )} + {item.status === "error" && ( + + )} + {item.status === "done" && item.docId && ( + + )} +
+ ))} +
+ )}
); } -// ── Page ──────────────────────────────────────────────────────────────────── +// ── Document row ────────────────────────────────────────────────────────────── -export default function DocumentsPage() { - const qc = useQueryClient(); - const fileRef = useRef(null); - const [newCatName, setNewCatName] = useState(""); - const [uploadError, setUploadError] = useState(null); - const [searchParams, setSearchParams] = useSearchParams(); - - const categoryIdFromUrl = searchParams.get("category_id") ?? undefined; - - const [params, setParams] = useState({ - page: 1, - per_page: 20, - sort: "created_at", - order: "desc", - category_id: categoryIdFromUrl, - }); - - // Sync category_id from URL into params - useEffect(() => { - setParams((prev) => ({ ...prev, category_id: categoryIdFromUrl, page: 1 })); - }, [categoryIdFromUrl]); - - const updateParams = useCallback((patch: Partial) => { - setParams((prev) => ({ ...prev, ...patch })); - }, []); - - const clearCategoryFilter = useCallback(() => { - setSearchParams((sp) => { - sp.delete("category_id"); - return sp; - }); - }, [setSearchParams]); - - const { data: docPage, isLoading } = useQuery({ - queryKey: ["documents", params], - queryFn: () => listDocuments(params), - }); - - const documents = docPage?.items ?? []; - const total = docPage?.total ?? 0; - const pages = docPage?.pages ?? 1; - - const { data: categories = [] } = useQuery({ - queryKey: ["categories"], - queryFn: listCategories, - }); - - const activeCategory = categoryIdFromUrl - ? categories.find((c) => c.id === categoryIdFromUrl) - : undefined; - - const uploadMut = useMutation({ - mutationFn: uploadDocument, - onSuccess: () => { - setUploadError(null); - qc.invalidateQueries({ queryKey: ["documents"] }); - }, - onError: (err: unknown) => { - const msg = - (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? - "Upload failed"; - setUploadError(msg); - }, - }); - - const deleteMut = useMutation({ - mutationFn: deleteDocument, - onSuccess: () => qc.invalidateQueries({ queryKey: ["documents"] }), - }); - - const createCatMut = useMutation({ - mutationFn: createCategory, - onSuccess: () => { - setNewCatName(""); - qc.invalidateQueries({ queryKey: ["categories"] }); - }, - }); - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) uploadMut.mutate(file); - e.target.value = ""; - }; +function DocumentRow({ + doc, isOwner, selected, onToggle, onOpen, +}: { doc: DocumentOut; isOwner: boolean; selected: boolean; onToggle: () => void; onOpen: () => void }) { + const cats = doc.categories.slice(0, 2); + const extraCats = doc.categories.length - 2; return ( - <> -
-

Documents

+ + { e.stopPropagation(); onToggle(); }}> + {selected + ? + : } + - {/* Upload */} -
- - - {uploadError && ( - {uploadError} + +
+ {!isOwner && ( + + )} +
+

+ {doc.title ?? {doc.filename}} +

+ {doc.title && ( +

{doc.filename}

+ )} +
+
+ + + + {doc.document_type && ( + {doc.document_type} + )} + + + + + + + +
+ {cats.map((c) => ( + + {c.name} + + ))} + {extraCats > 0 && ( + +{extraCats} )}
+ - {/* Category management */} -
- Manage categories -
- {categories.map((c) => ( - - {c.name} - - ))} + + {isOwner && doc.share_count > 0 && ( + + + + )} + + + + {formatDate(doc.created_at)} + + + + {formatBytes(doc.file_size)} + + + + + + + ); +} + +// ── Sortable column header ──────────────────────────────────────────────────── + +function SortHeader({ + label, colKey, currentSort, currentOrder, onSort, +}: { label: string; colKey: string; currentSort: string; currentOrder: string; onSort: (key: string) => void }) { + const active = currentSort === colKey; + return ( + onSort(colKey)} + > + + {label} + {active && ( + currentOrder === "asc" + ? + : + )} + + + ); +} + +// ── Main page ───────────────────────────────────────────────────────────────── + +export default function DocumentsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const fileInputRef = useRef(null); + + const view = searchParams.get("view") ?? "all"; + const page = parseInt(searchParams.get("page") ?? "1", 10); + const sort = searchParams.get("sort") ?? "created_at"; + const order = (searchParams.get("order") ?? "desc") as "asc" | "desc"; + const search = searchParams.get("search") ?? ""; + const status = searchParams.get("status") ?? undefined; + const document_type = searchParams.get("document_type") ?? undefined; + const category_id = searchParams.get("category_id") ?? undefined; + + const [searchInput, setSearchInput] = useState(search); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [activeDocId, setActiveDocId] = useState(null); + const [uploadQueue, setUploadQueue] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const dragCounter = useRef(0); + + // Debounce search input → URL param + useEffect(() => { + const timer = setTimeout(() => { + setSearchParams((p) => { + const n = new URLSearchParams(p); + if (searchInput) n.set("search", searchInput); else n.delete("search"); + n.set("page", "1"); + return n; + }); + }, 400); + return () => clearTimeout(timer); + }, [searchInput]); + + // Sync searchInput if URL param changes externally + useEffect(() => { setSearchInput(search); }, [search]); + + const isSharedView = view === "shared"; + + const queryParams: DocumentListParams = { + page, per_page: 20, sort, order, + ...(status && { status }), + ...(document_type && { document_type }), + ...(search && { search }), + ...(category_id && { category_id }), + }; + + const { data: ownedPage, isLoading: ownedLoading } = useQuery({ + queryKey: ["documents", queryParams], + queryFn: () => listDocuments(queryParams), + enabled: !isSharedView, + refetchInterval: (query) => { + const items = (query.state.data as { items?: DocumentOut[] })?.items ?? []; + return items.some((d) => d.status === "pending" || d.status === "processing") ? 3000 : false; + }, + }); + + const { data: sharedPage, isLoading: sharedLoading } = useQuery({ + queryKey: ["documents-shared", queryParams], + queryFn: () => listSharedWithMe(queryParams), + enabled: isSharedView, + }); + + const data = isSharedView ? sharedPage : ownedPage; + const isLoading = isSharedView ? sharedLoading : ownedLoading; + const docs = data?.items ?? []; + const total = data?.total ?? 0; + const pages = data?.pages ?? 1; + + const activeDoc = docs.find((d) => d.id === activeDocId) ?? null; + const isOwnerView = !isSharedView; + + // ── Upload ──────────────────────────────────────────────────────────────── + + async function handleFiles(files: File[]) { + const pdfs = files.filter((f) => f.type === "application/pdf" || f.name.endsWith(".pdf")); + if (pdfs.length === 0) return; + + const items: UploadItem[] = pdfs.map((f) => ({ + localId: Math.random().toString(36).slice(2), + filename: f.name, + status: "uploading", + })); + setUploadQueue((q) => [...q, ...items]); + + await Promise.all( + pdfs.map(async (file, i) => { + const localId = items[i].localId; + try { + const doc = await uploadDocument(file); + setUploadQueue((q) => + q.map((item) => + item.localId === localId ? { ...item, status: "done", docId: doc.id } : item + ) + ); + queryClient.invalidateQueries({ queryKey: ["documents"] }); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? + "Upload failed"; + setUploadQueue((q) => + q.map((item) => + item.localId === localId ? { ...item, status: "error", error: msg } : item + ) + ); + } + }) + ); + } + + // ── Drag and drop ───────────────────────────────────────────────────────── + + const onDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current++; + setIsDragging(true); + }, []); + + const onDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current--; + if (dragCounter.current === 0) setIsDragging(false); + }, []); + + const onDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); }, []); + + const onDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + dragCounter.current = 0; + setIsDragging(false); + handleFiles(Array.from(e.dataTransfer.files)); + }, []); + + // ── Sort ────────────────────────────────────────────────────────────────── + + function handleSort(colKey: string) { + setSearchParams((p) => { + const n = new URLSearchParams(p); + if (n.get("sort") === colKey) { + n.set("order", n.get("order") === "asc" ? "desc" : "asc"); + } else { + n.set("sort", colKey); + n.set("order", "desc"); + } + n.set("page", "1"); + return n; + }); + } + + // ── Selection ───────────────────────────────────────────────────────────── + + function toggleAll() { + if (selectedIds.size === docs.length && docs.length > 0) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(docs.map((d) => d.id))); + } + } + + function toggleOne(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + + // ── Pagination ──────────────────────────────────────────────────────────── + + function setPage(p: number) { + setSearchParams((prev) => { + const n = new URLSearchParams(prev); + n.set("page", String(p)); + return n; + }); + setSelectedIds(new Set()); + } + + const viewLabel = + isSharedView ? "Shared with me" : + view === "mine" ? "Mine" : + "All Documents"; + + return ( +
+ {/* Drag overlay */} + {isDragging && ( +
+
+ +

Drop PDFs to upload

-
{ - e.preventDefault(); - if (newCatName.trim()) createCatMut.mutate(newCatName.trim()); - }} - > - setNewCatName(e.target.value)} - placeholder="New category name" - style={{ padding: "6px 10px", fontSize: 13 }} - /> - -
-
+
+ )} - {/* Filter bar */} - - - {/* Document list */} - {isLoading ? ( -

Loading…

- ) : documents.length === 0 ? ( -

- {total === 0 && !params.search && !params.status && !params.document_type && !params.category_id - ? "No documents yet. Upload a PDF to get started." - : "No documents match the current filters."} -

- ) : ( + {/* Header */} +
+
+

{viewLabel}

+ {!isLoading && ( +

{total} document{total !== 1 ? "s" : ""}

+ )} +
+ {!isSharedView && ( <> - {documents.map((doc) => ( - deleteMut.mutate(id)} - /> - ))} - {pages > 1 && ( - updateParams({ page: p })} - /> - )} + + { + handleFiles(Array.from(e.target.files ?? [])); + e.target.value = ""; + }} + /> )}
- + + {/* Toolbar: search + filter chips */} +
+
+ + setSearchInput(e.target.value)} + placeholder="Search documents…" + className="pl-8 h-8 w-56 text-sm" + /> + {searchInput && ( + + )} +
+ +
+ + {/* Table */} +
+ + + + + + + + + + + + + + + {isLoading && ( + + + + )} + {!isLoading && docs.length === 0 && ( + + + + )} + {!isLoading && docs.map((doc) => ( + toggleOne(doc.id)} + onOpen={() => setActiveDocId(doc.id)} + /> + ))} + +
+ {selectedIds.size > 0 && selectedIds.size === docs.length + ? + : } + TypeStatusCategoriesShared +
+ +
+
+ + {total === 0 && !status && !document_type && !search && !category_id + ? isSharedView + ? "No documents have been shared with you yet." + : "No documents yet. Upload a PDF or drag and drop to get started." + : "No documents match the current filters."} +
+
+
+ + {/* Pagination */} + {pages > 1 && ( +
+

+ Page {page} of {pages} ({total} total) +

+
+ + +
+
+ )} + + {/* Bulk action bar (owner view only) */} + {selectedIds.size > 0 && isOwnerView && ( + setSelectedIds(new Set())} /> + )} + + {/* Upload queue panel */} + {uploadQueue.length > 0 && ( + setActiveDocId(id)} /> + )} + + {/* Document slide-over */} + {activeDoc && ( + setActiveDocId(null)} + onDeleted={() => { + setActiveDocId(null); + queryClient.invalidateQueries({ queryKey: ["documents"] }); + }} + /> + )} +
); }