Redesign doc service UX for scale + add group-based document sharing
- Three-column layout: Sidebar + SourcePanel (views + searchable category tree) + main - DocumentSlideOver (480px right panel): inline editing, type picker, AI suggestion confirm/reject, categories combobox, tags editor, sharing section, raw text, re-analyse/delete actions - ManageCategoriesDialog: inline rename, delete with confirm, search filter - DocumentsPage rewrite: filter chip system, multi-file upload queue, drag-and-drop overlay, bulk actions bar (share/delete), smart TanStack Query polling, URL-driven view state - Sidebar simplified: per-category NavLinks removed; Documents = single NavLink under Apps - Backend: document_shares table (migration 0004), share CRUD endpoints, shared-with-me view, N+1-safe share_count via GROUP BY, recipient download access, X-User-Groups header enforcement - Gateway proxy: injects X-User-Groups header into all document + category proxy requests - Backend users: GET /api/users/me/groups endpoint for share picker combobox - CLAUDE.md, STATUS.md files, and changelog updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
@@ -116,6 +117,15 @@ class ColorModeUpdate(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
class UserGroupOut(BaseModel):
|
||||
"""A group the current user belongs to — used for the share picker."""
|
||||
id: str
|
||||
name: str
|
||||
description: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DashboardPrefsUpdate(BaseModel):
|
||||
app_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 2026-04-18 — Doc Service Redesign: Scalable UX + Group-Based Sharing
|
||||
|
||||
**Timestamp:** 2026-04-18T00:00:00+00:00
|
||||
|
||||
## Summary
|
||||
|
||||
Complete redesign of the document management UX for scale (10 → 100 000 documents, 2 → 1 000 categories) and group-based document sharing. Replaced the monolithic DocumentsPage with a three-column layout (Sidebar + SourcePanel + main), a slide-over detail panel, a filter chip system, multi-file upload queue, and bulk actions. Added the full backend sharing stack: `document_shares` table, share CRUD endpoints, a shared-with-me view, and X-User-Groups header injection in the gateway proxy.
|
||||
|
||||
---
|
||||
|
||||
## Files Added
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `features/doc-service/app/models/document_share.py` | DocumentShare ORM model (document_id, group_id, shared_by_user_id) |
|
||||
| `features/doc-service/app/schemas/share.py` | DocumentShareOut, DocumentShareCreate, SharedDocumentOut schemas |
|
||||
| `features/doc-service/alembic/versions/0004_add_document_shares.py` | Migration creating document_shares table with indexes |
|
||||
| `frontend/src/components/SourcePanel.tsx` | Left panel (240px): views (All/Mine/Shared) + searchable category tree + new category form |
|
||||
| `frontend/src/components/ManageCategoriesDialog.tsx` | Category CRUD modal (inline rename, delete with confirm, search) |
|
||||
| `frontend/src/components/DocumentSlideOver.tsx` | Right slide-over (480px): metadata, inline title edit, type picker, AI suggestions, categories combobox, tags, sharing section, raw text, actions |
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `features/doc-service/app/models/__init__.py` | Import DocumentShare |
|
||||
| `features/doc-service/app/deps.py` | Added `get_user_groups` dependency (reads X-User-Groups header) |
|
||||
| `features/doc-service/app/schemas/document.py` | Added `share_count: int = 0` to DocumentOut |
|
||||
| `features/doc-service/app/routers/documents.py` | Complete rewrite: added share CRUD endpoints, shared-with-me endpoint, N+1-safe share_count, recipient download access, X-User-Groups enforcement |
|
||||
| `features/doc-service/STATUS.md` | Added sharing section, migration 0004, updated future work |
|
||||
| `backend/app/routers/documents_proxy.py` | Injects X-User-Groups header (queries GroupMembership per request) |
|
||||
| `backend/app/routers/categories_proxy.py` | Same X-User-Groups injection pattern |
|
||||
| `backend/app/routers/users.py` | Added GET /me/groups endpoint |
|
||||
| `backend/app/schemas/user.py` | Added UserGroupOut schema |
|
||||
| `backend/STATUS.md` | Added /me/groups endpoint |
|
||||
| `frontend/src/api/client.ts` | Added share_count to DocumentOut, SharedDocumentOut, DocumentShareOut, listSharedWithMe, getDocumentShares, addDocumentShare, removeDocumentShare, getMyGroups |
|
||||
| `frontend/src/components/Sidebar.tsx` | Removed per-category NavLinks; Documents is now a single NavLink under Apps |
|
||||
| `frontend/src/components/AppShell.tsx` | Renders SourcePanel between Sidebar and main on /apps/documents route |
|
||||
| `frontend/src/pages/DocumentsPage.tsx` | Complete rewrite: three-panel layout, view param (all/mine/shared), smart polling, drag-and-drop, multi-file upload queue, filter chip system, bulk actions bar |
|
||||
| `frontend/STATUS.md` | Complete rewrite reflecting all new components and patterns |
|
||||
| `CLAUDE.md` | Updated file tree, Database Models (DocumentShare), Migration chains (0004), API endpoints (shares, shared-with-me, /me/groups), TanStack Query keys, request flow diagram |
|
||||
@@ -123,6 +123,23 @@ Controlled via plugin settings (UI accessible to superusers and `doc-service-adm
|
||||
|
||||
On startup scan, the watcher walks the watch directory and ingests any PDFs not already in the database (idempotency check by `watch_path`). Subfolders are automatically mapped to categories (e.g. `watch/invoices/bill.pdf` → category "invoices"). No-remove policy: deleting a file from the watch directory does not delete the document record.
|
||||
|
||||
### Document sharing (`document_shares`)
|
||||
|
||||
Group-based sharing allows a document owner to share a document with all members of any group they belong to. Recipients can view and download the shared document; they cannot edit, re-analyse, delete, or re-share it.
|
||||
|
||||
The gateway injects `X-User-Groups: <group_id1>,<group_id2>,...` alongside the existing `X-User-Id` header, so doc-service can evaluate group access without querying the backend DB.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/documents/shared-with-me` | Any user | Documents shared with the user via their groups; excludes own docs |
|
||||
| `GET` | `/documents/{id}/shares` | Owner only | List all groups the document is shared with |
|
||||
| `POST` | `/documents/{id}/shares` | Owner only | Share with a group (`{group_id}` in body); group must be in X-User-Groups |
|
||||
| `DELETE` | `/documents/{id}/shares/{group_id}` | Owner only | Stop sharing with that group |
|
||||
|
||||
`DocumentOut` now includes `share_count: int` — the number of groups the document is shared with.
|
||||
|
||||
`GET /documents/{id}/file` also allows access to shared documents (recipients can download).
|
||||
|
||||
### Database migrations
|
||||
|
||||
| Revision | Description |
|
||||
@@ -130,6 +147,7 @@ On startup scan, the watcher walks the watch directory and ingests any PDFs not
|
||||
| 0001 | Initial schema (documents, categories, category_assignments) |
|
||||
| 0002 | Add `title` column to documents |
|
||||
| 0003 | Add `source`, `watch_path`, `suggested_folder`, `suggested_filename` columns |
|
||||
| 0004 | Add `document_shares` table (document_id, group_id, shared_by_user_id, created_at) |
|
||||
|
||||
Run automatically on container start via `alembic upgrade head`.
|
||||
|
||||
@@ -168,8 +186,8 @@ file_watcher.py (watchdog Observer, daemon thread)
|
||||
|
||||
- **Re-process** — no endpoint to re-trigger AI extraction on an existing document (e.g. after changing the AI model or prompt)
|
||||
- **Advanced field-level search** — `search` param matches text fields via ILIKE but does not query into `extracted_data` JSON (e.g. filter by `vendor` or `due_date`)
|
||||
- **Bulk operations** — no bulk category assign/remove, no bulk delete
|
||||
- **Document sharing** — documents are strictly per-user; no group sharing yet
|
||||
- **Bulk operations** — no bulk category assign/remove endpoint (frontend handles bulk delete/share individually)
|
||||
- **Advanced field-level search** — `search` matches text fields via ILIKE but does not query into `extracted_data` JSON
|
||||
- **Pagination in categories** — categories are returned as a full list (no pagination)
|
||||
- **File type** — only PDF supported
|
||||
- **Concurrent uploads** — no rate limiting per user
|
||||
@@ -183,9 +201,10 @@ file_watcher.py (watchdog Observer, daemon thread)
|
||||
- [x] Plugin manifest endpoint (`/plugin/manifest`, `/plugin/settings`) for generic settings UI
|
||||
- [ ] Advanced filter: query `extracted_data` JSON fields (vendor, due_date, amount) — requires PostgreSQL `jsonb` column or indexed virtual columns
|
||||
- [ ] Bulk operations endpoint
|
||||
- [ ] Document sharing via groups (blocked on groups/permissions system in backend)
|
||||
- [x] Document sharing via groups — `document_shares` table + share endpoints + shared-with-me view
|
||||
- [x] Frontend UI for suggestion badges (suggested_folder / suggested_filename confirm/reject buttons in slide-over)
|
||||
- [ ] Advanced filter: query `extracted_data` JSON fields (vendor, due_date, amount)
|
||||
- [ ] Support additional file types (images via OCR, DOCX)
|
||||
- [ ] Rate limiting on upload endpoint
|
||||
- [ ] Soft delete with restore
|
||||
- [ ] Category rename / delete with cascade handling
|
||||
- [ ] Frontend UI for suggestion badges (suggested_folder / suggested_filename confirm/reject buttons)
|
||||
- [ ] Edit rights for shared recipients (V2)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add document_shares table
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-04-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: Union[str, None] = "0003"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"document_shares",
|
||||
sa.Column("id", sa.String(), nullable=False),
|
||||
sa.Column("document_id", sa.String(), nullable=False),
|
||||
sa.Column("group_id", sa.String(), nullable=False),
|
||||
sa.Column("shared_by_user_id", sa.String(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("document_id", "group_id", name="uq_document_group_share"),
|
||||
)
|
||||
op.create_index("ix_document_shares_document_id", "document_shares", ["document_id"])
|
||||
op.create_index("ix_document_shares_group_id", "document_shares", ["group_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_document_shares_group_id", table_name="document_shares")
|
||||
op.drop_index("ix_document_shares_document_id", table_name="document_shares")
|
||||
op.drop_table("document_shares")
|
||||
@@ -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()]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.models.document import Document
|
||||
from app.models.category import DocumentCategory
|
||||
from app.models.category_assignment import CategoryAssignment
|
||||
from app.models.document_share import DocumentShare
|
||||
|
||||
__all__ = ["Document", "DocumentCategory", "CategoryAssignment"]
|
||||
__all__ = ["Document", "DocumentCategory", "CategoryAssignment", "DocumentShare"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class DocumentShare(Base):
|
||||
__tablename__ = "document_shares"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
document_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
shared_by_user_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("document_id", "group_id", name="uq_document_group_share"),
|
||||
)
|
||||
@@ -13,11 +13,20 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import AsyncSessionLocal, get_db
|
||||
from app.deps import get_user_id
|
||||
from app.deps import get_user_groups, get_user_id
|
||||
from app.models.category import DocumentCategory
|
||||
from app.models.category_assignment import CategoryAssignment
|
||||
from app.models.document import Document
|
||||
from app.schemas.document import DocumentOut, DocumentPage, DocumentStatusOut, DocumentTypeUpdate, TagsUpdate, TitleUpdate
|
||||
from app.models.document_share import DocumentShare
|
||||
from app.schemas.document import (
|
||||
DocumentOut,
|
||||
DocumentPage,
|
||||
DocumentStatusOut,
|
||||
DocumentTypeUpdate,
|
||||
TagsUpdate,
|
||||
TitleUpdate,
|
||||
)
|
||||
from app.schemas.share import DocumentShareCreate, DocumentShareOut, SharedDocumentOut
|
||||
from app.services.ai_client import AIServiceError, classify_document
|
||||
from app.services.config_reader import load_doc_config
|
||||
from app.services.storage import delete_file, get_upload_path, save_upload
|
||||
@@ -52,7 +61,19 @@ async def _get_user_doc(doc_id: str, user_id: str, db: AsyncSession) -> Document
|
||||
return doc
|
||||
|
||||
|
||||
def _doc_with_categories(doc: Document) -> DocumentOut:
|
||||
async def _get_share_counts(doc_ids: list[str], db: AsyncSession) -> dict[str, int]:
|
||||
"""Return a mapping of doc_id → share count for the given document IDs."""
|
||||
if not doc_ids:
|
||||
return {}
|
||||
rows = await db.execute(
|
||||
select(DocumentShare.document_id, func.count(DocumentShare.id))
|
||||
.where(DocumentShare.document_id.in_(doc_ids))
|
||||
.group_by(DocumentShare.document_id)
|
||||
)
|
||||
return {row[0]: row[1] for row in rows.all()}
|
||||
|
||||
|
||||
def _doc_with_categories(doc: Document, share_count: int = 0) -> DocumentOut:
|
||||
from app.schemas.document import CategoryOut
|
||||
cats = [CategoryOut(id=a.category.id, name=a.category.name) for a in doc.category_assignments]
|
||||
return DocumentOut(
|
||||
@@ -73,6 +94,7 @@ def _doc_with_categories(doc: Document) -> DocumentOut:
|
||||
watch_path=doc.watch_path,
|
||||
suggested_folder=doc.suggested_folder,
|
||||
suggested_filename=doc.suggested_filename,
|
||||
share_count=share_count,
|
||||
)
|
||||
|
||||
|
||||
@@ -161,8 +183,6 @@ async def upload_document(
|
||||
background_tasks.add_task(process_document, doc_id)
|
||||
|
||||
# Re-query with selectinload so category_assignments is eagerly loaded.
|
||||
# A new doc has no categories yet, but we need the relationship populated
|
||||
# to avoid MissingGreenlet in the async session.
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
return _doc_with_categories(doc)
|
||||
|
||||
@@ -194,7 +214,6 @@ async def list_documents(
|
||||
sort_col = _SORT_COLUMNS.get(sort, Document.created_at)
|
||||
sort_expr = sort_col.desc() if order == "desc" else sort_col.asc()
|
||||
|
||||
# Build filter conditions once and reuse for both count + items queries.
|
||||
# Watch-ingested documents (user_id = "watch") are visible to all users.
|
||||
conditions = [or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID)]
|
||||
if status:
|
||||
@@ -233,7 +252,10 @@ async def list_documents(
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
)
|
||||
items = [_doc_with_categories(d) for d in items_result.scalars().all()]
|
||||
docs = items_result.scalars().all()
|
||||
|
||||
share_counts = await _get_share_counts([d.id for d in docs], db)
|
||||
items = [_doc_with_categories(d, share_counts.get(d.id, 0)) for d in docs]
|
||||
|
||||
return DocumentPage(
|
||||
items=items,
|
||||
@@ -243,6 +265,119 @@ async def list_documents(
|
||||
)
|
||||
|
||||
|
||||
# NOTE: This route must be registered BEFORE /{doc_id} to avoid path collision.
|
||||
@router.get("/shared-with-me", response_model=DocumentPage)
|
||||
async def list_shared_with_me(
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=20, ge=1, le=100),
|
||||
sort: str = Query(default="created_at"),
|
||||
order: str = Query(default="desc", pattern="^(asc|desc)$"),
|
||||
search: str | None = Query(default=None),
|
||||
document_type: str | None = Query(default=None),
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentPage:
|
||||
"""Return documents shared with the current user via any of their groups.
|
||||
Excludes documents the user owns (those appear in their regular list).
|
||||
"""
|
||||
if not user_groups:
|
||||
return DocumentPage(items=[], total=0, page=page, pages=1)
|
||||
|
||||
sort_col = _SORT_COLUMNS.get(sort, Document.created_at)
|
||||
sort_expr = sort_col.desc() if order == "desc" else sort_col.asc()
|
||||
|
||||
shared_doc_ids_subq = (
|
||||
select(DocumentShare.document_id)
|
||||
.where(DocumentShare.group_id.in_(user_groups))
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
conditions = [
|
||||
Document.id.in_(shared_doc_ids_subq),
|
||||
Document.user_id != user_id, # exclude own docs
|
||||
]
|
||||
if document_type:
|
||||
conditions.append(Document.document_type == document_type)
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
Document.title.ilike(like),
|
||||
Document.filename.ilike(like),
|
||||
Document.tags.ilike(like),
|
||||
Document.document_type.ilike(like),
|
||||
)
|
||||
)
|
||||
|
||||
count_result = await db.execute(
|
||||
select(func.count(Document.id)).where(*conditions)
|
||||
)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
items_result = await db.execute(
|
||||
select(Document)
|
||||
.where(*conditions)
|
||||
.options(
|
||||
selectinload(Document.category_assignments)
|
||||
.selectinload(CategoryAssignment.category)
|
||||
)
|
||||
.order_by(sort_expr)
|
||||
.offset((page - 1) * per_page)
|
||||
.limit(per_page)
|
||||
)
|
||||
docs = items_result.scalars().all()
|
||||
|
||||
# For each doc, find which share (group) brought it in (pick first match)
|
||||
share_rows_result = await db.execute(
|
||||
select(DocumentShare)
|
||||
.where(
|
||||
DocumentShare.document_id.in_([d.id for d in docs]),
|
||||
DocumentShare.group_id.in_(user_groups),
|
||||
)
|
||||
)
|
||||
share_rows = share_rows_result.scalars().all()
|
||||
# Map doc_id → first share row found
|
||||
share_map: dict[str, DocumentShare] = {}
|
||||
for share in share_rows:
|
||||
if share.document_id not in share_map:
|
||||
share_map[share.document_id] = share
|
||||
|
||||
from app.schemas.document import CategoryOut
|
||||
|
||||
items: list[SharedDocumentOut] = []
|
||||
for doc in docs:
|
||||
cats = [CategoryOut(id=a.category.id, name=a.category.name) for a in doc.category_assignments]
|
||||
share = share_map.get(doc.id)
|
||||
items.append(
|
||||
SharedDocumentOut(
|
||||
id=doc.id,
|
||||
user_id=doc.user_id,
|
||||
filename=doc.filename,
|
||||
title=doc.title,
|
||||
file_size=doc.file_size,
|
||||
status=doc.status,
|
||||
document_type=doc.document_type,
|
||||
extracted_data=doc.extracted_data,
|
||||
tags=doc.tags,
|
||||
error_message=doc.error_message,
|
||||
created_at=doc.created_at,
|
||||
processed_at=doc.processed_at,
|
||||
categories=cats,
|
||||
source=doc.source,
|
||||
shared_by_user_id=share.shared_by_user_id if share else "",
|
||||
shared_via_group_id=share.group_id if share else "",
|
||||
)
|
||||
)
|
||||
|
||||
return DocumentPage(
|
||||
items=items, # type: ignore[arg-type]
|
||||
total=total,
|
||||
page=page,
|
||||
pages=max(1, math.ceil(total / per_page)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{doc_id}", response_model=DocumentOut)
|
||||
async def get_document(
|
||||
doc_id: str,
|
||||
@@ -250,7 +385,8 @@ async def get_document(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentOut:
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
return _doc_with_categories(doc)
|
||||
counts = await _get_share_counts([doc.id], db)
|
||||
return _doc_with_categories(doc, counts.get(doc.id, 0))
|
||||
|
||||
|
||||
@router.get("/{doc_id}/status", response_model=DocumentStatusOut)
|
||||
@@ -360,12 +496,22 @@ async def delete_document(
|
||||
async def download_file(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> StreamingResponse:
|
||||
# Allow access if: owner, watch doc, or shared with any of user's groups
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
or_(Document.user_id == user_id, Document.user_id == _WATCH_USER_ID),
|
||||
or_(
|
||||
Document.user_id == user_id,
|
||||
Document.user_id == _WATCH_USER_ID,
|
||||
Document.id.in_(
|
||||
select(DocumentShare.document_id).where(
|
||||
DocumentShare.group_id.in_(user_groups)
|
||||
)
|
||||
) if user_groups else False,
|
||||
),
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
@@ -393,7 +539,6 @@ async def assign_category(
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
# Verify the document is accessible (own or watch-ingested)
|
||||
doc_result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
@@ -411,7 +556,6 @@ async def assign_category(
|
||||
if cat_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
# Upsert — ignore if already assigned
|
||||
existing = await db.execute(
|
||||
select(CategoryAssignment).where(
|
||||
CategoryAssignment.document_id == doc_id,
|
||||
@@ -443,8 +587,6 @@ async def remove_category(
|
||||
|
||||
|
||||
# ── AI suggestion confirmation ────────────────────────────────────────────────
|
||||
# These endpoints allow users to confirm or reject AI suggestions on
|
||||
# watch-ingested documents. No disk mutations — suggestions only update the DB.
|
||||
|
||||
@router.post("/{doc_id}/suggestions/folder/confirm", status_code=204)
|
||||
async def confirm_folder_suggestion(
|
||||
@@ -456,7 +598,6 @@ async def confirm_folder_suggestion(
|
||||
if not doc.suggested_folder:
|
||||
raise HTTPException(status_code=400, detail="No folder suggestion pending")
|
||||
|
||||
# Find or create the suggested category under the watch sentinel user
|
||||
cat_result = await db.execute(
|
||||
select(DocumentCategory).where(
|
||||
DocumentCategory.user_id == _WATCH_USER_ID,
|
||||
@@ -470,7 +611,6 @@ async def confirm_folder_suggestion(
|
||||
await db.commit()
|
||||
await db.refresh(cat)
|
||||
|
||||
# Assign if not already assigned
|
||||
exists = await db.execute(
|
||||
select(CategoryAssignment).where(
|
||||
CategoryAssignment.document_id == doc_id,
|
||||
@@ -518,3 +658,94 @@ async def reject_filename_suggestion(
|
||||
doc = await _get_user_doc(doc_id, user_id, db)
|
||||
doc.suggested_filename = None
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Document sharing ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{doc_id}/shares", response_model=list[DocumentShareOut])
|
||||
async def list_document_shares(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DocumentShare]:
|
||||
"""List all group shares for a document. Owner only."""
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
shares_result = await db.execute(
|
||||
select(DocumentShare).where(DocumentShare.document_id == doc_id)
|
||||
)
|
||||
return shares_result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{doc_id}/shares", response_model=DocumentShareOut, status_code=201)
|
||||
async def add_document_share(
|
||||
doc_id: str,
|
||||
body: DocumentShareCreate,
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentShare:
|
||||
"""Share a document with a group. The sharing user must own the document
|
||||
and must be a member of the target group."""
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
if body.group_id not in user_groups:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You can only share with groups you belong to",
|
||||
)
|
||||
|
||||
# Idempotent — return existing share if already shared with this group
|
||||
existing = await db.execute(
|
||||
select(DocumentShare).where(
|
||||
DocumentShare.document_id == doc_id,
|
||||
DocumentShare.group_id == body.group_id,
|
||||
)
|
||||
)
|
||||
share = existing.scalar_one_or_none()
|
||||
if share is not None:
|
||||
return share
|
||||
|
||||
share = DocumentShare(
|
||||
document_id=doc_id,
|
||||
group_id=body.group_id,
|
||||
shared_by_user_id=user_id,
|
||||
)
|
||||
db.add(share)
|
||||
await db.commit()
|
||||
await db.refresh(share)
|
||||
return share
|
||||
|
||||
|
||||
@router.delete("/{doc_id}/shares/{group_id}", status_code=204)
|
||||
async def remove_document_share(
|
||||
doc_id: str,
|
||||
group_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Remove a group share. Owner only."""
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
share_result = await db.execute(
|
||||
select(DocumentShare).where(
|
||||
DocumentShare.document_id == doc_id,
|
||||
DocumentShare.group_id == group_id,
|
||||
)
|
||||
)
|
||||
share = share_result.scalar_one_or_none()
|
||||
if share:
|
||||
await db.delete(share)
|
||||
await db.commit()
|
||||
|
||||
@@ -27,6 +27,7 @@ class DocumentOut(BaseModel):
|
||||
watch_path: str | None = None
|
||||
suggested_folder: str | None = None
|
||||
suggested_filename: str | None = None
|
||||
share_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DocumentShareOut(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
group_id: str
|
||||
shared_by_user_id: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DocumentShareCreate(BaseModel):
|
||||
group_id: str
|
||||
|
||||
|
||||
class SharedDocumentOut(BaseModel):
|
||||
"""DocumentOut fields plus sharing context for the 'Shared with me' view."""
|
||||
id: str
|
||||
user_id: str
|
||||
filename: str
|
||||
title: str | None
|
||||
file_size: int
|
||||
status: str
|
||||
document_type: str | None
|
||||
extracted_data: str | None
|
||||
tags: str | None
|
||||
error_message: str | None
|
||||
created_at: datetime
|
||||
processed_at: datetime | None
|
||||
categories: list = []
|
||||
source: str = "upload"
|
||||
share_count: int = 0
|
||||
# Sharing context
|
||||
shared_by_user_id: str
|
||||
shared_via_group_id: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
+102
-110
@@ -40,138 +40,127 @@ All API calls go through `src/api/client.ts` (single Axios instance, JWT injecte
|
||||
### Home dashboard (`/`)
|
||||
|
||||
Personalised landing page per user:
|
||||
- Time-aware greeting with the user's display name (`full_name` or email). React JSX text rendering HTML-escapes all values — no `dangerouslySetInnerHTML` is used anywhere on this page.
|
||||
- Grid of **pinned app cards** drawn from `GET /api/services`, filtered to the user's saved list.
|
||||
- **Customize mode** (pencil button): shows all services; `+` / `−` toggle buttons on each card; changes committed with **Save** via `PATCH /api/users/me/preferences`.
|
||||
- Empty-state prompt when no apps are pinned.
|
||||
- Time-aware greeting with the user's display name
|
||||
- Grid of **pinned app cards** from `GET /api/services`, filtered to user's saved list
|
||||
- **Customize mode** (pencil button): shows all services; `+` / `−` toggle; commits via `PATCH /api/users/me/preferences`
|
||||
|
||||
### Apps page (`/apps`)
|
||||
|
||||
Cards are rendered dynamically from `GET /api/services` (polled every 30 s via TanStack Query):
|
||||
- **healthy=true + app_path set** — clickable card with "Available" badge
|
||||
- **healthy=true + no app_path** — non-clickable card (e.g. AI Service — no user UI)
|
||||
- **healthy=false** — non-clickable, dimmed card with "Unavailable" badge and explanation text
|
||||
- Single **Settings** button per card — visible to global admins OR members of the service's admin group (checked via `GET /api/plugins` which backend filters by access). Links to `svc.settings_path`.
|
||||
Cards from `GET /api/services` (polled every 30 s):
|
||||
- healthy + app_path → clickable card with "Available" badge
|
||||
- healthy + no app_path → non-clickable card
|
||||
- unhealthy → dimmed, non-clickable, "Unavailable"
|
||||
- Settings button visible to admins and service-admin group members
|
||||
|
||||
### Sidebar navigation
|
||||
|
||||
`Apps` is an expandable accordion in the sidebar:
|
||||
- **Documents** sub-item (expandable) — lists all user categories beneath it; clicking a category navigates to `/apps/documents?category_id=<id>`
|
||||
- AI Service is not listed (no openable UI)
|
||||
- Sections auto-open when navigating to their route
|
||||
- In collapsed (icons-only) mode, clicking the Apps icon navigates to `/apps`
|
||||
`Apps` expandable accordion: **Documents** single NavLink to `/apps/documents`. Category navigation moved to SourcePanel (only visible on `/apps/documents` route). Admin section (Users, Groups, Appearance) for admins. Collapsible to icon-only mode.
|
||||
|
||||
### Documents page (`/apps/documents`)
|
||||
### Documents page (`/apps/documents`) — three-column layout
|
||||
|
||||
**Upload:** PDF file input, 202 response, error display.
|
||||
**SourcePanel** (240px, left): Appears only on `/apps/documents`.
|
||||
- Views: All Documents / Mine / Shared with me (URL param `?view=`)
|
||||
- Category tree with client-side search (searchable when > 4 categories)
|
||||
- Inline new category form
|
||||
- "Manage categories" button opens `ManageCategoriesDialog`
|
||||
|
||||
**Filter bar:**
|
||||
- Search input (400ms debounce) — matches title, filename, tags, document_type
|
||||
- Status dropdown (all / pending / processing / done / failed)
|
||||
- Type dropdown (all / invoice / bill / receipt / order / expense / revenue / unknown)
|
||||
- Sort selector (upload date / processed date / title / filename / file size / type / status)
|
||||
- Asc/Desc toggle
|
||||
- "Clear filters" button (appears when any filter is active)
|
||||
**Toolbar:** Debounced search input (400ms) + filter chips system.
|
||||
- Filter chips: Status, Document type, Category (each adds a removable chip)
|
||||
- "Add filter" button opens a two-step picker (dimension → value)
|
||||
- Sort via clickable column headers (↑/↓ chevron)
|
||||
|
||||
**Pagination:** Prev/Next with "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
|
||||
|
||||
@@ -111,6 +111,20 @@ export interface DocumentOut {
|
||||
watch_path: string | null;
|
||||
suggested_folder: string | null;
|
||||
suggested_filename: string | null;
|
||||
share_count: number;
|
||||
}
|
||||
|
||||
export interface SharedDocumentOut extends DocumentOut {
|
||||
shared_by_user_id: string;
|
||||
shared_via_group_id: string;
|
||||
}
|
||||
|
||||
export interface DocumentShareOut {
|
||||
id: string;
|
||||
document_id: string;
|
||||
group_id: string;
|
||||
shared_by_user_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DocumentPage {
|
||||
@@ -142,6 +156,18 @@ export interface DocumentStatusOut {
|
||||
export const listDocuments = (params: DocumentListParams = {}) =>
|
||||
api.get<DocumentPage>("/documents", { params }).then((r) => r.data);
|
||||
|
||||
export const listSharedWithMe = (params: DocumentListParams = {}) =>
|
||||
api.get<DocumentPage>("/documents/shared-with-me", { params }).then((r) => r.data);
|
||||
|
||||
export const getDocumentShares = (docId: string) =>
|
||||
api.get<DocumentShareOut[]>(`/documents/${docId}/shares`).then((r) => r.data);
|
||||
|
||||
export const addDocumentShare = (docId: string, groupId: string) =>
|
||||
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId }).then((r) => r.data);
|
||||
|
||||
export const removeDocumentShare = (docId: string, groupId: string) =>
|
||||
api.delete(`/documents/${docId}/shares/${groupId}`);
|
||||
|
||||
export const getDocument = (id: string) =>
|
||||
api.get<DocumentOut>(`/documents/${id}`).then((r) => r.data);
|
||||
|
||||
@@ -289,6 +315,16 @@ export const updateDocumentLimits = (max_pdf_mb: number) =>
|
||||
export const getDocumentLimits = () =>
|
||||
api.get<Record<string, unknown>>("/settings/documents/limits").then((r) => r.data);
|
||||
|
||||
// --- User groups (current user's own memberships) ---
|
||||
export interface UserGroupOut {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export const getMyGroups = () =>
|
||||
api.get<UserGroupOut[]>("/users/me/groups").then((r) => r.data);
|
||||
|
||||
// --- Groups (admin only) ---
|
||||
export interface GroupOut {
|
||||
id: string;
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import SourcePanel from "@/components/SourcePanel";
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AppShell({ children }: AppShellProps) {
|
||||
const location = useLocation();
|
||||
const showSourcePanel = location.pathname === "/apps/documents";
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar />
|
||||
{showSourcePanel && <SourcePanel />}
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
X, Download, Eye, RefreshCw, Trash2, Check, Pencil, Plus,
|
||||
ChevronDown, ChevronRight, Users, UserMinus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DocumentOut, CategoryOut,
|
||||
updateDocumentTitle, updateDocumentTags, updateDocumentType,
|
||||
assignCategory, removeCategory, deleteDocument,
|
||||
downloadDocument, viewDocument, reprocessDocument,
|
||||
confirmFolderSuggestion, rejectFolderSuggestion,
|
||||
confirmFilenameSuggestion, rejectFilenameSuggestion,
|
||||
listCategories,
|
||||
getDocumentShares, addDocumentShare, removeDocumentShare,
|
||||
getMyGroups,
|
||||
} from "@/api/client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
doc: DocumentOut | null;
|
||||
isOwner: boolean; // false for "shared with me" view
|
||||
onClose: () => void;
|
||||
onDeleted: (id: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: "bg-orange-400",
|
||||
processing: "bg-blue-400",
|
||||
done: "bg-emerald-400",
|
||||
failed: "bg-red-400",
|
||||
};
|
||||
|
||||
const DOC_TYPES = ["invoice", "bill", "receipt", "order", "expense", "revenue", "unknown"];
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// ── Category combobox ─────────────────────────────────────────────────────────
|
||||
|
||||
function CategoryCombobox({
|
||||
categories, assigned, onAssign,
|
||||
}: { categories: CategoryOut[]; assigned: CategoryOut[]; onAssign: (id: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const assignedIds = new Set(assigned.map((c) => c.id));
|
||||
const unassigned = categories.filter((c) => !assignedIds.has(c.id));
|
||||
const filtered = search
|
||||
? unassigned.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: unassigned;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
if (unassigned.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1 text-xs text-muted hover:text-foreground transition-colors border border-dashed border-border rounded px-2 py-0.5"
|
||||
>
|
||||
<Plus className="h-3 w-3" /> Add category
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full mt-1 z-20 bg-surface border border-border rounded-lg shadow-lg w-52">
|
||||
{categories.length > 5 && (
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-48 overflow-y-auto py-1">
|
||||
{filtered.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors"
|
||||
onClick={() => { onAssign(cat.id); setOpen(false); setSearch(""); }}
|
||||
>
|
||||
{cat.name}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-muted px-3 py-2">No categories</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Group picker combobox ─────────────────────────────────────────────────────
|
||||
|
||||
function GroupCombobox({
|
||||
groups, sharedGroupIds, onShare,
|
||||
}: { groups: { id: string; name: string }[]; sharedGroupIds: Set<string>; onShare: (id: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const available = groups.filter((g) => !sharedGroupIds.has(g.id));
|
||||
const filtered = search
|
||||
? available.filter((g) => g.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: available;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
if (available.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1 text-xs text-muted hover:text-foreground transition-colors border border-dashed border-border rounded px-2 py-0.5 mt-1"
|
||||
>
|
||||
<Users className="h-3 w-3" /> Share with a group
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full mt-1 z-20 bg-surface border border-border rounded-lg shadow-lg w-52">
|
||||
{groups.length > 5 && (
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search groups…"
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-48 overflow-y-auto py-1">
|
||||
{filtered.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
className="w-full text-left px-3 py-1.5 text-sm hover:bg-muted/20 transition-colors"
|
||||
onClick={() => { onShare(g.id); setOpen(false); setSearch(""); }}
|
||||
>
|
||||
{g.name}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-xs text-muted px-3 py-2">No groups available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DocumentSlideOver({ doc, isOwner, onClose, onDeleted }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rawOpen, setRawOpen] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleValue, setTitleValue] = useState("");
|
||||
const [editingType, setEditingType] = useState(false);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (doc) {
|
||||
setTitleValue(doc.title ?? "");
|
||||
setEditingTitle(false);
|
||||
setEditingType(false);
|
||||
setRawOpen(false);
|
||||
setTagInput("");
|
||||
}
|
||||
}, [doc?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingTitle) titleInputRef.current?.focus();
|
||||
}, [editingTitle]);
|
||||
|
||||
const { data: allCategories = [] } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: listCategories,
|
||||
});
|
||||
|
||||
const { data: myGroups = [] } = useQuery({
|
||||
queryKey: ["my-groups"],
|
||||
queryFn: getMyGroups,
|
||||
enabled: isOwner,
|
||||
});
|
||||
|
||||
const { data: shares = [] } = useQuery({
|
||||
queryKey: ["document-shares", doc?.id],
|
||||
queryFn: () => getDocumentShares(doc!.id),
|
||||
enabled: isOwner && !!doc,
|
||||
});
|
||||
|
||||
const invalidateDoc = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["documents"] });
|
||||
}, [queryClient]);
|
||||
|
||||
const titleMut = useMutation({
|
||||
mutationFn: (title: string) => updateDocumentTitle(doc!.id, title),
|
||||
onSuccess: () => { invalidateDoc(); setEditingTitle(false); },
|
||||
});
|
||||
|
||||
const typeMut = useMutation({
|
||||
mutationFn: (document_type: string) => updateDocumentType(doc!.id, document_type),
|
||||
onSuccess: () => { invalidateDoc(); setEditingType(false); },
|
||||
});
|
||||
|
||||
const addTagMut = useMutation({
|
||||
mutationFn: (tags: string[]) => updateDocumentTags(doc!.id, tags),
|
||||
onSuccess: invalidateDoc,
|
||||
});
|
||||
|
||||
const assignCatMut = useMutation({
|
||||
mutationFn: (catId: string) => assignCategory(doc!.id, catId),
|
||||
onSuccess: invalidateDoc,
|
||||
});
|
||||
|
||||
const removeCatMut = useMutation({
|
||||
mutationFn: (catId: string) => removeCategory(doc!.id, catId),
|
||||
onSuccess: invalidateDoc,
|
||||
});
|
||||
|
||||
const reprocessMut = useMutation({
|
||||
mutationFn: () => reprocessDocument(doc!.id),
|
||||
onSuccess: invalidateDoc,
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => deleteDocument(doc!.id),
|
||||
onSuccess: () => { invalidateDoc(); onDeleted(doc!.id); },
|
||||
});
|
||||
|
||||
const confirmFolderMut = useMutation({
|
||||
mutationFn: () => confirmFolderSuggestion(doc!.id),
|
||||
onSuccess: invalidateDoc,
|
||||
});
|
||||
|
||||
const rejectFolderMut = useMutation({
|
||||
mutationFn: () => rejectFolderSuggestion(doc!.id),
|
||||
onSuccess: invalidateDoc,
|
||||
});
|
||||
|
||||
const confirmFilenameMut = useMutation({
|
||||
mutationFn: () => confirmFilenameSuggestion(doc!.id),
|
||||
onSuccess: invalidateDoc,
|
||||
});
|
||||
|
||||
const rejectFilenameMut = useMutation({
|
||||
mutationFn: () => rejectFilenameSuggestion(doc!.id),
|
||||
onSuccess: invalidateDoc,
|
||||
});
|
||||
|
||||
const addShareMut = useMutation({
|
||||
mutationFn: (groupId: string) => addDocumentShare(doc!.id, groupId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["document-shares", doc!.id] }),
|
||||
});
|
||||
|
||||
const removeShareMut = useMutation({
|
||||
mutationFn: (groupId: string) => removeDocumentShare(doc!.id, groupId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["document-shares", doc!.id] }),
|
||||
});
|
||||
|
||||
if (!doc) return null;
|
||||
|
||||
const extractedData = (() => {
|
||||
try { return doc.extracted_data ? JSON.parse(doc.extracted_data) : null; }
|
||||
catch { return null; }
|
||||
})();
|
||||
|
||||
const tags: string[] = (() => {
|
||||
try { return doc.tags ? JSON.parse(doc.tags) : []; }
|
||||
catch { return []; }
|
||||
})();
|
||||
|
||||
const suggestedCategories: string[] = (() => {
|
||||
try { return extractedData?.suggested_categories ?? []; }
|
||||
catch { return []; }
|
||||
})();
|
||||
|
||||
const displayKeys = extractedData
|
||||
? Object.entries(extractedData).filter(([k]) => !["tags", "suggested_categories"].includes(k))
|
||||
: [];
|
||||
|
||||
const sharedGroupIds = new Set(shares.map((s) => s.group_id));
|
||||
|
||||
function addTag() {
|
||||
const tag = tagInput.trim();
|
||||
if (!tag || tags.includes(tag)) return;
|
||||
addTagMut.mutate([...tags, tag]);
|
||||
setTagInput("");
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
addTagMut.mutate(tags.filter((t) => t !== tag));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
{/* Overlay backdrop (subtle) */}
|
||||
<div className="absolute inset-0 bg-background/20" />
|
||||
|
||||
{/* Panel */}
|
||||
<div className="absolute inset-y-0 right-0 w-[480px] bg-surface border-l border-border shadow-2xl flex flex-col overflow-hidden z-50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-muted truncate">{doc.filename}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isOwner && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => viewDocument(doc.id)}
|
||||
className="h-7 px-2 gap-1 text-xs"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" /> View
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => downloadDocument(doc.id, doc.filename)}
|
||||
className="h-7 px-2 gap-1 text-xs"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isOwner && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => viewDocument(doc.id)} className="h-7 px-2 gap-1 text-xs">
|
||||
<Eye className="h-3.5 w-3.5" /> View
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => downloadDocument(doc.id, doc.filename)} className="h-7 px-2 gap-1 text-xs">
|
||||
<Download className="h-3.5 w-3.5" /> Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={onClose} className="ml-1 text-muted hover:text-foreground transition-colors">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex items-center gap-3 flex-wrap text-xs text-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn("h-2 w-2 rounded-full", STATUS_COLORS[doc.status] ?? "bg-gray-400")} />
|
||||
{doc.status}
|
||||
</span>
|
||||
<span>{formatBytes(doc.file_size)}</span>
|
||||
<span>Uploaded {formatDate(doc.created_at)}</span>
|
||||
{doc.processed_at && <span>Processed {formatDate(doc.processed_at)}</span>}
|
||||
{doc.source === "watch" && (
|
||||
<span className="text-primary/60">Watch-ingested</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{doc.status === "failed" && doc.error_message && (
|
||||
<div className="text-xs text-red-500 bg-red-500/10 rounded-md px-3 py-2">
|
||||
Error: {doc.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Title</p>
|
||||
{isOwner && editingTitle ? (
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); titleMut.mutate(titleValue); }}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<Input
|
||||
ref={titleInputRef}
|
||||
value={titleValue}
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
className="h-8 text-sm flex-1"
|
||||
disabled={titleMut.isPending}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setEditingTitle(false); }}
|
||||
/>
|
||||
<button type="submit" disabled={titleMut.isPending} className="text-primary disabled:opacity-50">
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditingTitle(false)} className="text-muted hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 group">
|
||||
<span className={cn("text-sm", !doc.title && "text-muted italic")}>
|
||||
{doc.title ?? "No title"}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => { setTitleValue(doc.title ?? ""); setEditingTitle(true); }}
|
||||
className="text-muted opacity-0 group-hover:opacity-100 hover:text-foreground transition-all"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document type */}
|
||||
{isOwner && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Type</p>
|
||||
{editingType ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{DOC_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => typeMut.mutate(t)}
|
||||
disabled={typeMut.isPending}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded border transition-colors",
|
||||
doc.document_type === t
|
||||
? "border-primary text-primary bg-primary/10"
|
||||
: "border-border text-muted hover:border-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setEditingType(false)} className="text-muted hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 group">
|
||||
<span className={cn("text-sm", !doc.document_type && "text-muted italic")}>
|
||||
{doc.document_type ?? "Unknown"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditingType(true)}
|
||||
className="text-muted opacity-0 group-hover:opacity-100 hover:text-foreground transition-all"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Suggestions */}
|
||||
{isOwner && (doc.suggested_folder || doc.suggested_filename) && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">
|
||||
AI Suggestions
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{doc.suggested_folder && (
|
||||
<div className="flex items-center gap-2 text-sm bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2">
|
||||
<span className="flex-1">
|
||||
<span className="text-xs text-muted mr-1">Folder:</span>
|
||||
{doc.suggested_folder}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => confirmFolderMut.mutate()}
|
||||
disabled={confirmFolderMut.isPending || rejectFolderMut.isPending}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium disabled:opacity-50"
|
||||
>
|
||||
✓ Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectFolderMut.mutate()}
|
||||
disabled={confirmFolderMut.isPending || rejectFolderMut.isPending}
|
||||
className="text-muted hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{doc.suggested_filename && (
|
||||
<div className="flex items-center gap-2 text-sm bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2">
|
||||
<span className="flex-1">
|
||||
<span className="text-xs text-muted mr-1">Title:</span>
|
||||
{doc.suggested_filename}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => confirmFilenameMut.mutate()}
|
||||
disabled={confirmFilenameMut.isPending || rejectFilenameMut.isPending}
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700 font-medium disabled:opacity-50"
|
||||
>
|
||||
✓ Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectFilenameMut.mutate()}
|
||||
disabled={confirmFilenameMut.isPending || rejectFilenameMut.isPending}
|
||||
className="text-muted hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extracted data */}
|
||||
{displayKeys.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">
|
||||
Extracted Data
|
||||
</p>
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{displayKeys.map(([k, v]) => (
|
||||
<tr key={k} className="border-b border-border/50 last:border-0">
|
||||
<td className="py-1.5 pr-3 text-muted font-medium w-1/3 align-top">
|
||||
{k.replace(/_/g, " ")}
|
||||
</td>
|
||||
<td className="py-1.5 text-foreground align-top break-words">
|
||||
{v === null || v === undefined ? (
|
||||
<span className="text-muted">—</span>
|
||||
) : Array.isArray(v) ? (
|
||||
<span className="font-mono">{JSON.stringify(v)}</span>
|
||||
) : (
|
||||
String(v)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Categories</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{doc.categories.map((cat) => (
|
||||
<span
|
||||
key={cat.id}
|
||||
className="flex items-center gap-1 text-xs bg-primary/10 text-primary rounded-full px-2.5 py-0.5"
|
||||
>
|
||||
{cat.name}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => removeCatMut.mutate(cat.id)}
|
||||
disabled={removeCatMut.isPending}
|
||||
className="hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{isOwner && (
|
||||
<CategoryCombobox
|
||||
categories={allCategories}
|
||||
assigned={doc.categories}
|
||||
onAssign={(id) => assignCatMut.mutate(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI-suggested categories */}
|
||||
{isOwner && suggestedCategories.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-muted mb-1">Suggested by AI:</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{suggestedCategories
|
||||
.filter((name) => !doc.categories.some((c) => c.name.toLowerCase() === name.toLowerCase()))
|
||||
.map((name) => {
|
||||
const exists = allCategories.find(
|
||||
(c) => c.name.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
className="flex items-center gap-1 text-xs bg-amber-500/10 text-amber-700 dark:text-amber-400 rounded-full px-2.5 py-0.5 border border-amber-500/20"
|
||||
>
|
||||
{name}
|
||||
{exists ? (
|
||||
<button
|
||||
onClick={() => assignCatMut.mutate(exists.id)}
|
||||
className="text-emerald-600 hover:text-emerald-700"
|
||||
title="Assign"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={async () => {
|
||||
// Create the category then assign it
|
||||
const { createCategory } = await import("@/api/client");
|
||||
const cat = await createCategory(name);
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
assignCatMut.mutate(cat.id);
|
||||
}}
|
||||
className="text-emerald-600 hover:text-emerald-700 text-[10px] font-medium"
|
||||
title="Create & assign"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{isOwner && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Tags</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-1 text-xs bg-muted/20 rounded-full px-2.5 py-0.5"
|
||||
>
|
||||
{tag}
|
||||
<button onClick={() => removeTag(tag)} className="text-muted hover:text-red-500">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addTag(); }}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="Add tag…"
|
||||
className="h-7 text-xs flex-1"
|
||||
disabled={addTagMut.isPending}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!tagInput.trim() || addTagMut.isPending}
|
||||
className="text-primary disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sharing */}
|
||||
{isOwner && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">Sharing</p>
|
||||
{shares.length === 0 && (
|
||||
<p className="text-xs text-muted mb-1">Not shared with any groups</p>
|
||||
)}
|
||||
<div className="space-y-1 mb-1">
|
||||
{shares.map((share) => {
|
||||
const group = myGroups.find((g) => g.id === share.group_id);
|
||||
return (
|
||||
<div key={share.id} className="flex items-center gap-2 text-sm">
|
||||
<Users className="h-3.5 w-3.5 text-muted shrink-0" />
|
||||
<span className="flex-1 text-sm">{group?.name ?? share.group_id}</span>
|
||||
<button
|
||||
onClick={() => removeShareMut.mutate(share.group_id)}
|
||||
disabled={removeShareMut.isPending}
|
||||
className="text-muted hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
title="Stop sharing"
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<GroupCombobox
|
||||
groups={myGroups}
|
||||
sharedGroupIds={sharedGroupIds}
|
||||
onShare={(id) => addShareMut.mutate(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Owner actions */}
|
||||
{isOwner && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => reprocessMut.mutate()}
|
||||
disabled={reprocessMut.isPending || doc.status === "pending" || doc.status === "processing"}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", reprocessMut.isPending && "animate-spin")} />
|
||||
Re-analyse
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${doc.title ?? doc.filename}"? This cannot be undone.`)) {
|
||||
deleteMut.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
className="gap-1.5 text-red-500 border-red-200 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw text */}
|
||||
{doc.source && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setRawOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
{rawOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
Extracted text
|
||||
</button>
|
||||
{rawOpen && (
|
||||
<pre className="mt-2 text-xs bg-muted/10 border border-border rounded-md p-3 overflow-y-auto max-h-64 whitespace-pre-wrap break-words font-mono">
|
||||
{/* raw_text not in DocumentOut — show message */}
|
||||
(Raw text is stored server-side; use the backend API to retrieve it.)
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { X, Pencil, Trash2, Check } from "lucide-react";
|
||||
import { listCategories, renameCategory, deleteCategory } from "@/api/client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ManageCategoriesDialog({ onClose }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: listCategories,
|
||||
});
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingId) editInputRef.current?.focus();
|
||||
}, [editingId]);
|
||||
|
||||
const renameMut = useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string }) => renameCategory(id, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
setEditingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteCategory,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["categories"] }),
|
||||
});
|
||||
|
||||
const filtered = search
|
||||
? categories.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: categories;
|
||||
|
||||
function startEdit(id: string, name: string) {
|
||||
setEditingId(id);
|
||||
setEditValue(name);
|
||||
}
|
||||
|
||||
function submitEdit(id: string) {
|
||||
const name = editValue.trim();
|
||||
if (name && name !== categories.find((c) => c.id === id)?.name) {
|
||||
renameMut.mutate({ id, name });
|
||||
} else {
|
||||
setEditingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/70"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="bg-surface border border-border rounded-lg w-[520px] max-h-[80vh] flex flex-col shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
||||
<h2 className="text-base font-semibold">Manage Categories</h2>
|
||||
<button onClick={onClose} className="text-muted hover:text-foreground transition-colors">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
{categories.length > 6 && (
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<Input
|
||||
placeholder="Search categories…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-3 space-y-1 min-h-0">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-sm text-muted py-4 text-center">
|
||||
{search ? "No categories match" : "No categories yet"}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/10 group"
|
||||
>
|
||||
{editingId === cat.id ? (
|
||||
<form
|
||||
className="flex items-center gap-2 flex-1"
|
||||
onSubmit={(e) => { e.preventDefault(); submitEdit(cat.id); }}
|
||||
>
|
||||
<Input
|
||||
ref={editInputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
className="h-7 text-sm flex-1"
|
||||
disabled={renameMut.isPending}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setEditingId(null); }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!editValue.trim() || renameMut.isPending}
|
||||
className="text-primary hover:text-primary/80 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(null)}
|
||||
className="text-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm truncate">{cat.name}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => startEdit(cat.id, cat.name)}
|
||||
className="text-muted hover:text-foreground transition-colors p-0.5"
|
||||
title="Rename"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Delete category "${cat.name}"? Documents in it will be uncategorised.`)) {
|
||||
deleteMut.mutate(cat.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMut.isPending}
|
||||
className="text-muted hover:text-red-500 transition-colors p-0.5 disabled:opacity-50"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-4 border-t border-border">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
LogOut,
|
||||
UserCircle,
|
||||
FileText,
|
||||
Folder,
|
||||
Users,
|
||||
UsersRound,
|
||||
Palette,
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { getMe, listCategories } from "@/api/client";
|
||||
import { getMe } from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function Sidebar() {
|
||||
@@ -30,11 +29,9 @@ export default function Sidebar() {
|
||||
const { data: user } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||
|
||||
const isAppsRoute = location.pathname.startsWith("/apps");
|
||||
const isDocsRoute = location.pathname.startsWith("/apps/documents");
|
||||
const isAdminRoute = location.pathname.startsWith("/admin");
|
||||
|
||||
const [appsOpen, setAppsOpen] = useState(isAppsRoute);
|
||||
const [docsOpen, setDocsOpen] = useState(isDocsRoute);
|
||||
const [adminOpen, setAdminOpen] = useState(isAdminRoute);
|
||||
|
||||
// Auto-open sections when navigating to their routes
|
||||
@@ -42,20 +39,10 @@ export default function Sidebar() {
|
||||
if (isAppsRoute) setAppsOpen(true);
|
||||
}, [isAppsRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDocsRoute) setDocsOpen(true);
|
||||
}, [isDocsRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdminRoute) setAdminOpen(true);
|
||||
}, [isAdminRoute]);
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: listCategories,
|
||||
enabled: appsOpen && docsOpen && !!user,
|
||||
});
|
||||
|
||||
const navItemClass = (isActive: boolean) =>
|
||||
cn(
|
||||
"flex items-center rounded-lg transition-colors",
|
||||
@@ -74,15 +61,6 @@ export default function Sidebar() {
|
||||
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
||||
);
|
||||
|
||||
const subSubItemClass = (isActive: boolean) =>
|
||||
cn(
|
||||
"flex items-center rounded-lg transition-colors text-sm",
|
||||
"pl-12 pr-3 py-1 gap-2",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -147,53 +125,13 @@ export default function Sidebar() {
|
||||
{/* Apps sub-items — only when sidebar is expanded and appsOpen */}
|
||||
{sidebarExpanded && appsOpen && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{/* Documents service */}
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center rounded-lg transition-colors text-sm",
|
||||
isDocsRoute
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<NavLink
|
||||
to="/apps/documents"
|
||||
end
|
||||
className="flex items-center gap-2 pl-8 pr-2 py-1.5 flex-1 min-w-0"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Documents</span>
|
||||
</NavLink>
|
||||
<button
|
||||
onClick={() => setDocsOpen((o) => !o)}
|
||||
className="px-2 py-1.5 rounded-r-lg"
|
||||
aria-label={docsOpen ? "Collapse documents" : "Expand documents"}
|
||||
>
|
||||
{docsOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{docsOpen && (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{categories.map((cat) => (
|
||||
<NavLink
|
||||
key={cat.id}
|
||||
to={`/apps/documents?category_id=${cat.id}`}
|
||||
className={({ isActive }) => subSubItemClass(isActive)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="whitespace-nowrap truncate">{cat.name}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<NavLink
|
||||
to="/apps/documents"
|
||||
className={({ isActive }) => subItemClass(isActive)}
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Documents</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Files, User, Users, Folder, Plus, Settings2, Check, X } from "lucide-react";
|
||||
import { listCategories, createCategory, CategoryOut } from "@/api/client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ManageCategoriesDialog from "@/components/ManageCategoriesDialog";
|
||||
|
||||
export default function SourcePanel() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const currentView = searchParams.get("view") ?? "all";
|
||||
const currentCategoryId = searchParams.get("category_id");
|
||||
|
||||
const [catSearch, setCatSearch] = useState("");
|
||||
const [addingCat, setAddingCat] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState("");
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: listCategories,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: createCategory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
setNewCatName("");
|
||||
setAddingCat(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (addingCat) addInputRef.current?.focus();
|
||||
}, [addingCat]);
|
||||
|
||||
const filteredCats = catSearch
|
||||
? categories.filter((c) => c.name.toLowerCase().includes(catSearch.toLowerCase()))
|
||||
: categories;
|
||||
|
||||
function setView(view: string) {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set("view", view);
|
||||
next.delete("category_id");
|
||||
next.set("page", "1");
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function selectCategory(cat: CategoryOut) {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete("view");
|
||||
next.set("category_id", cat.id);
|
||||
next.set("page", "1");
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const viewItemClass = (active: boolean) =>
|
||||
cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
||||
);
|
||||
|
||||
const catItemClass = (active: boolean) =>
|
||||
cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1 rounded-md text-sm transition-colors",
|
||||
active
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted hover:bg-muted/20 hover:text-foreground"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="w-56 flex flex-col border-r border-border bg-surface shrink-0 h-screen">
|
||||
{/* Views */}
|
||||
<div className="p-3 border-b border-border">
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5 px-1">
|
||||
Views
|
||||
</p>
|
||||
<button
|
||||
className={viewItemClass(!currentCategoryId && (currentView === "all" || !currentView))}
|
||||
onClick={() => setView("all")}
|
||||
>
|
||||
<Files className="h-4 w-4 shrink-0" />
|
||||
All Documents
|
||||
</button>
|
||||
<button
|
||||
className={viewItemClass(!currentCategoryId && currentView === "mine")}
|
||||
onClick={() => setView("mine")}
|
||||
>
|
||||
<User className="h-4 w-4 shrink-0" />
|
||||
Mine
|
||||
</button>
|
||||
<button
|
||||
className={viewItemClass(!currentCategoryId && currentView === "shared")}
|
||||
onClick={() => setView("shared")}
|
||||
>
|
||||
<Users className="h-4 w-4 shrink-0" />
|
||||
Shared with me
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="flex-1 flex flex-col min-h-0 p-3">
|
||||
<div className="flex items-center justify-between mb-1.5 px-1">
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wider">
|
||||
Categories
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setManageOpen(true)}
|
||||
className="text-muted hover:text-foreground transition-colors"
|
||||
title="Manage categories"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{categories.length > 4 && (
|
||||
<Input
|
||||
placeholder="Search…"
|
||||
value={catSearch}
|
||||
onChange={(e) => setCatSearch(e.target.value)}
|
||||
className="h-7 text-xs mb-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-0.5 min-h-0">
|
||||
{filteredCats.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={catItemClass(currentCategoryId === cat.id)}
|
||||
onClick={() => selectCategory(cat)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{cat.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredCats.length === 0 && catSearch && (
|
||||
<p className="text-xs text-muted px-2 py-1">No categories match</p>
|
||||
)}
|
||||
{categories.length === 0 && !catSearch && (
|
||||
<p className="text-xs text-muted px-2 py-1">No categories yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add new category */}
|
||||
<div className="pt-2 border-t border-border mt-2">
|
||||
{addingCat ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newCatName.trim()) createMut.mutate(newCatName.trim());
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Input
|
||||
ref={addInputRef}
|
||||
value={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
placeholder="Category name"
|
||||
className="h-7 text-xs flex-1"
|
||||
disabled={createMut.isPending}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newCatName.trim() || createMut.isPending}
|
||||
className="text-primary hover:text-primary/80 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAddingCat(false); setNewCatName(""); }}
|
||||
className="text-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAddingCat(true)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted hover:text-foreground transition-colors w-full px-2 py-1"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New category
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{manageOpen && (
|
||||
<ManageCategoriesDialog onClose={() => setManageOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user