1 Commits

Author SHA1 Message Date
6d38b793f4 Add Fetch Request pipeline UI with real-time SSEs (#8)
## Summary

Add Fetch Request pipeline UI with real-time SSE progress tracking, ambiguity resolution, list page with filtering/retry, and detail page with stepper/event feed.

## Changes

### New files

- **`src/FetchRequestDetail.tsx`** (+675 lines) — Full detail page for a single fetch request with:
  - SSE connection for real-time pipeline progress (`/fetch-requests/:id/events`)
  - 4-step stepper (Extract → Raw Expense → Enrich → Save) with active/completed/failed/paused states
  - Granular progress bar (10% Extract, 20% Raw Expense, 50% Enrich, 20% Save)
  - Progress event feed with auto-scroll and deduplication (only latest `progress` per step, hides `started` when terminal event follows)
  - Ambiguity resolution cards with candidate selection buttons
  - Retry support with retry-count progress bar
  - Failure/success snackbar notifications
  - Error display for failed fetch requests

### Modified files

- **`react-openapi/api/client.ts`** — added `api.patch()` method for PATCH requests
- **`react-openapi/hooks/useResource.ts`** — added `usePatch()` mutation hook for partial updates with cache invalidation
- **`src/FetchRequests.tsx`** (+347/−73 lines) — Major list page rewrite:
  - Row-level actions: retry (failed <3 retries), navigate to detail (paused), delete with confirmation dialog
  - Filter bar: status multi-select, account text filter, file/email source toggle
  - Account name autocomplete from API
  - Format dropdown driven by `resourceOverrides` config
  - Date pickers (changed from `datetime-local` to `date`)
  - Copy fingerprint button, retry count display, date range column
  - Row click navigates to detail, sorted by `created_at` desc
  - `pause` status support in `statusColors`
  - 409 conflict handling on duplicate fingerprint
  - `formatApiError()` for 422 validation error display
- **`src/features/fetch-requests/fetch-requests.models.ts`** — Added types:
  - `paused` to `FetchRequestStatus`
  - `FetchRequestUpdate` interface
  - `retry_count` to `FetchRequest` interface
  - `raw_lines`, `txn_blocks`, `txn_dicts`, `txn_dict_count`/`txn_dicts_count` to source types
  - `PendingAmbiguity`, `AmbiguityCandidate`, `ResolveAmbiguityPayload`
  - `SSEEvent`, `SSEEventStep`, `SSEEventStatus`, `ProgressMessage`
  - `FetchRequestFilters`
  - `formatApiError()` helper for FastAPI 422 error parsing
  - `RETRY_MAX = 3` constant
- **`src/features/fetch-requests/index.ts`** — Barrel exports for all new types, hooks, and helpers
- **`src/features/fetch-requests/useFetchRequests.ts`** — Added hooks:
  - `useUpdateFetchRequest()` — PATCH via `usePatch`
  - `useFetchRequestAmbiguities(id)` — queries `/fetch-requests/:id/ambiguities`
  - `useResolveAmbiguity()` — posts to `/ambiguities/:id/resolve` with cache invalidation
- **`src/main.jsx`** — Added route `/fetch-requests/:id` → `FetchRequestDetail`

## Key decisions

- SSE event stream is the single source of truth for progress; REST is only fallback for page load
- Stepper shows granular ratio-based progress counts (e.g. `150/246` for enrich)
- `pipeline/failed` SSE event triggers refetch + error snackbar
- `load_content` events excluded from event feed entirely
- Enrich/Save progress counts come only from SSE (no REST fallback since those phases don't pause)

Reviewed-on: #8
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-05-30 15:58:48 +00:00
4 changed files with 23 additions and 6 deletions

View File

@@ -36,7 +36,7 @@ import type {
SSEEvent, SSEEvent,
ProgressMessage, ProgressMessage,
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import { RETRY_MAX } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useConfig } from "../react-openapi"; import { useConfig } from "../react-openapi";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = { const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
@@ -318,8 +318,8 @@ export default function FetchRequestDetail() {
if (!id) return; if (!id) return;
try { try {
await updateMutation.mutateAsync({ id, data: { status: "pending" } }); await updateMutation.mutateAsync({ id, data: { status: "pending" } });
} catch { } catch (err: any) {
// handled by react query setFailNotif(formatApiError(err));
} }
}; };

View File

@@ -55,7 +55,7 @@ import type {
FileSource, FileSource,
EmailSource, EmailSource,
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import { RETRY_MAX } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useResourceByName, useConfig } from "../react-openapi"; import { useResourceByName, useConfig } from "../react-openapi";
@@ -180,7 +180,7 @@ export default function FetchRequests() {
if (err?.response?.status === 409) { if (err?.response?.status === 409) {
setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" }); setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" });
} else { } else {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" }); setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
} }
} }
}; };

View File

@@ -113,4 +113,21 @@ export interface FetchRequestFilters {
source_type?: "file" | "email"; source_type?: "file" | "email";
} }
export function formatApiError(err: any): string {
if (!err?.response) return err?.message || "Request failed";
const data = err.response.data;
const status = err.response.status;
if (status === 422 && Array.isArray(data?.detail)) {
return data.detail.map((d: any) => {
const field = d.loc?.filter((s: string) => s !== "body").pop() || "field";
if (d.type === "value_error.missing") return `Missing: ${field}`;
return `${field}: ${d.msg}`;
}).join("; ");
}
if (typeof data?.detail === "string") return data.detail;
return `Request failed (${status})`;
}
export const RETRY_MAX = 3; export const RETRY_MAX = 3;

View File

@@ -15,7 +15,7 @@ export type {
SSEEventStatus, SSEEventStatus,
ProgressMessage, ProgressMessage,
} from "./fetch-requests.models"; } from "./fetch-requests.models";
export { RETRY_MAX } from "./fetch-requests.models"; export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
export { export {
useFetchRequestsList, useFetchRequestsList,
useFetchRequest, useFetchRequest,