Add priority queue to ai-service and STATUS.md workflow
- Introduce async priority queue service in ai-service; all /chat calls now route through it - Refactor chat router to separate execute_chat (core logic) from the HTTP handler - Add /queue endpoints (status, pause, resume, cancel) for queue management - Update ai-service config to use Pydantic v2 model_config style - Add STATUS.md files for backend, ai-service, doc-service, and frontend - Document STATUS.md workflow in CLAUDE.md - Update doc-service documents router and schemas; frontend DocumentsPage and API client Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# Frontend — Status
|
||||
|
||||
## What it is
|
||||
|
||||
React 18 + TypeScript + Vite SPA. In dev it runs on port `5173` and proxies `/api/*` to `backend:8000`. In prod it is served by nginx on port `80`.
|
||||
|
||||
All API calls go through `src/api/client.ts` (single Axios instance, JWT injected via request interceptor from `localStorage`).
|
||||
|
||||
---
|
||||
|
||||
## Routes
|
||||
|
||||
| Path | Component | Auth |
|
||||
|------|-----------|------|
|
||||
| `/login` | `LoginPage` | Public |
|
||||
| `/` | `DashboardPage` | Required |
|
||||
| `/apps` | `AppsPage` | Required |
|
||||
| `/apps/documents` | `DocumentsPage` | Required |
|
||||
| `/apps/documents/settings/admin` | `DocumentAdminSettingsPage` | Admin only |
|
||||
| `/apps/ai/settings/admin` | `AIAdminSettingsPage` | Admin only |
|
||||
| `/admin` | `AdminPage` | Admin only |
|
||||
| `/profile` | `ProfilePage` | Required |
|
||||
|
||||
`PrivateRoute` redirects to `/login` when no token. `AdminRoute` redirects to `/` when not admin.
|
||||
|
||||
---
|
||||
|
||||
## Current functionality
|
||||
|
||||
### Auth
|
||||
|
||||
- Login form (`POST /api/auth/login`) stores JWT in `localStorage`
|
||||
- Logout clears token and redirects to `/login`
|
||||
- `GET /api/users/me` verifies token on protected routes
|
||||
|
||||
### Apps page (`/apps`)
|
||||
|
||||
Cards for each installed app:
|
||||
- **Documents** — link to `/apps/documents`; admin gear icon → `/apps/documents/settings/admin`
|
||||
- **AI Service** — infrastructure card; admin gear icon → `/apps/ai/settings/admin`; no Open button (no user-facing UI)
|
||||
|
||||
### Documents page (`/apps/documents`)
|
||||
|
||||
**Upload:** PDF file input, 202 response, error display.
|
||||
|
||||
**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)
|
||||
|
||||
**Pagination:** Prev/Next with "X–Y of Z" count. Only shown when total > per_page.
|
||||
|
||||
**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)
|
||||
|
||||
**Document row (expanded):**
|
||||
- **Tag editor** — read mode shows chips + Edit button; edit mode has removable chips + input (Enter/comma to add) + Save/Cancel
|
||||
- **Extracted data table** — all AI-extracted JSON fields (excludes `tags`, `suggested_categories`)
|
||||
- **Error message** — shown if status=failed
|
||||
- **Categories** — assigned chips with remove; dropdown to assign existing; AI-suggested chips with Accept / Create & Assign / Dismiss
|
||||
- **Status polling** — auto-refetches every 3s while status is pending/processing; invalidates document list on done/failed
|
||||
|
||||
### AI Admin Settings (`/apps/ai/settings/admin`)
|
||||
|
||||
- Provider selector (lmstudio / ollama / anthropic)
|
||||
- Per-provider fields (base URL, model, API key)
|
||||
- Test Connection button (`POST /api/settings/ai/test`)
|
||||
- Save button
|
||||
|
||||
### Document Admin Settings (`/apps/documents/settings/admin`)
|
||||
|
||||
- Upload Limits section only (max PDF size in MB)
|
||||
- Save button
|
||||
|
||||
### Admin page (`/admin`)
|
||||
|
||||
- User list with role and active status
|
||||
- Inline role/status editing
|
||||
|
||||
### Profile page (`/profile`)
|
||||
|
||||
- Display and edit personal information
|
||||
|
||||
---
|
||||
|
||||
## API client (`src/api/client.ts`)
|
||||
|
||||
Key functions:
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `listDocuments(params)` | `GET /documents` — returns `DocumentPage` |
|
||||
| `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` |
|
||||
| `getAISettings()` | `GET /settings/ai` (masked) |
|
||||
| `updateAISettings(data)` | `PATCH /settings/ai` |
|
||||
| `testAIConnection()` | `POST /settings/ai/test` |
|
||||
| `getDocumentLimits()` | `GET /settings/documents/limits` |
|
||||
| `updateDocumentLimits(data)` | `PATCH /settings/documents/limits` |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
---
|
||||
|
||||
## 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 UI component library** — raw inline styles throughout; Penpot + shadcn/ui evaluation pending
|
||||
- **No group/sharing UI** — blocked on backend groups system
|
||||
- **No app permission UI** — all apps visible to all authenticated users
|
||||
|
||||
---
|
||||
|
||||
## Future work
|
||||
|
||||
- [ ] UI component library decision (shadcn/ui recommended) + Penpot design system
|
||||
- [ ] Toast notification system (upload success, save feedback, errors)
|
||||
- [ ] Loading skeletons
|
||||
- [ ] `POST /queue/jobs` integration — show AI processing queue status / progress per document
|
||||
- [ ] Re-process document button (`POST /documents/{id}/reprocess` — needs backend endpoint first)
|
||||
- [ ] Advanced filter: extracted data fields (vendor, due date, amount) — needs backend support
|
||||
- [ ] Groups + document sharing UI — blocked on backend
|
||||
- [ ] App permissions UI in Admin page
|
||||
- [ ] `httpOnly` cookie auth (requires backend change)
|
||||
- [ ] Bulk document operations (select multiple, bulk delete / bulk categorise)
|
||||
@@ -98,6 +98,23 @@ export interface DocumentOut {
|
||||
categories: CategoryOut[];
|
||||
}
|
||||
|
||||
export interface DocumentPage {
|
||||
items: DocumentOut[];
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface DocumentListParams {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
sort?: string;
|
||||
order?: "asc" | "desc";
|
||||
status?: string;
|
||||
document_type?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface DocumentStatusOut {
|
||||
id: string;
|
||||
status: DocumentStatus;
|
||||
@@ -106,8 +123,8 @@ export interface DocumentStatusOut {
|
||||
processed_at: string | null;
|
||||
}
|
||||
|
||||
export const listDocuments = () =>
|
||||
api.get<DocumentOut[]>("/documents").then((r) => r.data);
|
||||
export const listDocuments = (params: DocumentListParams = {}) =>
|
||||
api.get<DocumentPage>("/documents", { params }).then((r) => r.data);
|
||||
|
||||
export const getDocument = (id: string) =>
|
||||
api.get<DocumentOut>(`/documents/${id}`).then((r) => r.data);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Nav from "../components/Nav";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
updateDocumentTitle,
|
||||
type DocumentOut,
|
||||
type CategoryOut,
|
||||
type DocumentListParams,
|
||||
} from "../api/client";
|
||||
|
||||
function StatusBadge({ status }: { status: DocumentOut["status"] }) {
|
||||
@@ -504,6 +505,140 @@ function DocumentRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Filter bar ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "created_at", label: "Upload date" },
|
||||
{ value: "processed_at", label: "Processed date" },
|
||||
{ value: "title", label: "Title" },
|
||||
{ value: "filename", label: "Filename" },
|
||||
{ value: "file_size", label: "File size" },
|
||||
{ value: "document_type", label: "Type" },
|
||||
{ value: "status", label: "Status" },
|
||||
];
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "processing", "done", "failed"];
|
||||
const TYPE_OPTIONS = ["invoice", "bill", "receipt", "order", "expense", "revenue", "unknown"];
|
||||
|
||||
function FilterBar({
|
||||
params,
|
||||
onChange,
|
||||
}: {
|
||||
params: DocumentListParams;
|
||||
onChange: (p: Partial<DocumentListParams>) => void;
|
||||
}) {
|
||||
const [searchInput, setSearchInput] = useState(params.search ?? "");
|
||||
|
||||
// Debounce search: commit after 400 ms of no typing
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => onChange({ search: searchInput || undefined, page: 1 }), 400);
|
||||
return () => clearTimeout(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchInput]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 16, alignItems: "center" }}>
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Search title, filename, tags…"
|
||||
style={{ padding: "6px 10px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4, width: 220 }}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={params.status ?? ""}
|
||||
onChange={(e) => onChange({ status: e.target.value || undefined, page: 1 })}
|
||||
style={{ padding: "6px 8px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4 }}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={params.document_type ?? ""}
|
||||
onChange={(e) => onChange({ document_type: e.target.value || undefined, page: 1 })}
|
||||
style={{ padding: "6px 8px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4 }}
|
||||
>
|
||||
<option value="">All types</option>
|
||||
{TYPE_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={params.sort ?? "created_at"}
|
||||
onChange={(e) => onChange({ sort: e.target.value, page: 1 })}
|
||||
style={{ padding: "6px 8px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4 }}
|
||||
>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => onChange({ order: params.order === "asc" ? "desc" : "asc", page: 1 })}
|
||||
title={params.order === "asc" ? "Ascending — click to reverse" : "Descending — click to reverse"}
|
||||
style={{ padding: "6px 10px", fontSize: 13, border: "1px solid #ccc", borderRadius: 4, cursor: "pointer", background: "#fff" }}
|
||||
>
|
||||
{params.order === "asc" ? "↑ Asc" : "↓ Desc"}
|
||||
</button>
|
||||
|
||||
{(params.search || params.status || params.document_type) && (
|
||||
<button
|
||||
onClick={() => { setSearchInput(""); onChange({ search: undefined, status: undefined, document_type: undefined, page: 1 }); }}
|
||||
style={{ padding: "6px 10px", fontSize: 12, border: "1px solid #ddd", borderRadius: 4, cursor: "pointer", color: "#666", background: "#fafafa" }}
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Pagination controls ──────────────────────────────────────────────────────
|
||||
|
||||
function Pagination({
|
||||
page,
|
||||
pages,
|
||||
total,
|
||||
perPage,
|
||||
onChange,
|
||||
}: {
|
||||
page: number;
|
||||
pages: number;
|
||||
total: number;
|
||||
perPage: number;
|
||||
onChange: (p: number) => void;
|
||||
}) {
|
||||
const start = (page - 1) * perPage + 1;
|
||||
const end = Math.min(page * perPage, total);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 16, fontSize: 13, color: "#555" }}>
|
||||
<button
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
style={{ padding: "4px 10px", cursor: page > 1 ? "pointer" : "default", borderRadius: 4, border: "1px solid #ccc", background: "#fff" }}
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span>
|
||||
{start}–{end} of {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={page >= pages}
|
||||
style={{ padding: "4px 10px", cursor: page < pages ? "pointer" : "default", borderRadius: 4, border: "1px solid #ccc", background: "#fff" }}
|
||||
>
|
||||
Next ›
|
||||
</button>
|
||||
<span style={{ color: "#aaa" }}>Page {page} / {pages}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DocumentsPage() {
|
||||
@@ -512,11 +647,26 @@ export default function DocumentsPage() {
|
||||
const [newCatName, setNewCatName] = useState("");
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const { data: documents = [], isLoading } = useQuery({
|
||||
queryKey: ["documents"],
|
||||
queryFn: listDocuments,
|
||||
const [params, setParams] = useState<DocumentListParams>({
|
||||
page: 1,
|
||||
per_page: 20,
|
||||
sort: "created_at",
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
const updateParams = useCallback((patch: Partial<DocumentListParams>) => {
|
||||
setParams((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const { data: docPage, isLoading } = useQuery({
|
||||
queryKey: ["documents", params],
|
||||
queryFn: () => listDocuments(params),
|
||||
});
|
||||
|
||||
const documents = docPage?.items ?? [];
|
||||
const total = docPage?.total ?? 0;
|
||||
const pages = docPage?.pages ?? 1;
|
||||
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: listCategories,
|
||||
@@ -558,7 +708,7 @@ export default function DocumentsPage() {
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<div style={{ padding: 32, maxWidth: 900, margin: "0 auto" }}>
|
||||
<div style={{ padding: 32, maxWidth: 960, margin: "0 auto" }}>
|
||||
<h1>Documents</h1>
|
||||
|
||||
{/* Upload */}
|
||||
@@ -611,20 +761,38 @@ export default function DocumentsPage() {
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{/* Filter bar */}
|
||||
<FilterBar params={params} onChange={updateParams} />
|
||||
|
||||
{/* Document list */}
|
||||
{isLoading ? (
|
||||
<p>Loading…</p>
|
||||
) : documents.length === 0 ? (
|
||||
<p style={{ color: "#666" }}>No documents yet. Upload a PDF to get started.</p>
|
||||
<p style={{ color: "#666" }}>
|
||||
{total === 0 && !params.search && !params.status && !params.document_type
|
||||
? "No documents yet. Upload a PDF to get started."
|
||||
: "No documents match the current filters."}
|
||||
</p>
|
||||
) : (
|
||||
documents.map((doc) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
categories={categories}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
/>
|
||||
))
|
||||
<>
|
||||
{documents.map((doc) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
categories={categories}
|
||||
onDelete={(id) => deleteMut.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
{pages > 1 && (
|
||||
<Pagination
|
||||
page={params.page ?? 1}
|
||||
pages={pages}
|
||||
total={total}
|
||||
perPage={params.per_page ?? 20}
|
||||
onChange={(p) => updateParams({ page: p })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user