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:
+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