From 5cf2a4c3c447b6aa7bccf447d12c28f176eb78ab Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 31 May 2026 09:28:26 +0000 Subject: [PATCH] Fetch Request UI (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add Fetch Request pipeline UI with real-time SSE progress tracking, ambiguity resolution, enhanced list page with filtering/retry, and detail page with stepper/event feed. Includes new API primitives (`api.patch`, `usePatch` hook) and extensive type definitions for SSE events, ambiguity resolution, and pipeline statuses. ## Changes by file ### `react-openapi/api/client.ts` - Added `api.patch()` method (delegates to `AxiosInstance.patch`) ### `react-openapi/hooks/useResource.ts` - Added `usePatch()` mutation hook — sends PATCH `/{endpoint}/{id}` with partial data, invalidates list + detail query caches on success ### `src/FetchRequestDetail.tsx` **(new, +675 lines)** Full detail page for a single fetch request, composed of: - **Header** — status chip, account name, source type (File/Email), date range, created/completed timestamps - **Progress bar** — `LinearProgress` with percentage computed via `computeProgressPercent()`: - Extract phase: snaps to 10% on first `raw_lines`/`txn_blocks` event - Raw Expense: ratio of `displayParsedCount` / `txnBlockCount` × 20% - Enrich: ratio of `stepStats.enrich_count` / `txnDictCount` × 50% - Save: ratio of `stepStats.save_count` / `txnDictCount` × 20% - **Retry section** — retry count bar (`retryCount/RETRY_MAX`) + "Retry" button if failed & not exhausted - **Error/success alerts** — `error_message` display, max-retries info - **Pipeline Stepper** — 4-step (`Extract` → `Raw Expense` → `Enrich` → `Save`) with custom icons per state: completed (CheckCircle, green), active (CircularProgress, animating), paused (WarningAmber, amber), failed (ErrorIcon, red), inactive (step number, grey). Step count labels shown below each label (e.g. `150/246`, `100/246`) - **Progress Events feed** — auto-scrolling list of SSE events with deduplication: - Only latest `progress` event per step (`txn_dicts`, `enrich`, `save_expenses`) - `started` events hidden when a terminal event (`completed`/`skipped`/`paused`/`failed`) follows - `load_content` events excluded entirely - Connection status indicator (green dot / red dot) - **Ambiguity Resolution** — When pipeline is `paused`, shows ambiguity cards: - Raw line in monospace code block - OCR amount/balance (struck through), previous balance - Candidate buttons with credit/debit coloring (green for positive, red for negative) - Resolved state shows green alert with chosen values - "All resolved" vs "Pipeline paused" alerts - **SSE connection** — `EventSource` to `{baseUrl}/fetch-requests/{id}/events`: - Tracks `progress`, `completed`, `paused`, `failed` events - On `paused`: refetch request + ambiguities - On `failed`: refetch request + show error snackbar from `message.error` - On `completed`/`resume_extract`: refetch request - Cleans up on unmount - **Snackbar** — pipeline failure notification (6s, bottom-center) ### `src/FetchRequests.tsx` **(+347/−73 lines)** Major enhancement to the list page: - **New filter bar** (replaces plain list header): - Status multi-select (pending, processing, paused, raw_expenses_done, enriched_done, completed, failed) - Account name text filter - Source type toggle (All / File / Email) - Refresh button - **Account autocomplete** — fetches accounts list via `useResourceByName("accounts")`, provides dropdown - **Format dropdown** — driven by `resourceOverrides` config (`fetchRes.fields.source.schema.format.options`), fallback `["axis", "icici_ocr"]` - **Date pickers** — changed from `datetime-local` to `date`, capped at today's date - **Navigation** — on create, navigates to `/fetch-requests/{id}` via `useNavigate` - **Row actions**: retry (failed, retry_count < 3), navigate to detail (paused), delete with confirmation dialog - **Copy fingerprint** — icon button copies to clipboard with snackbar confirmation - **Sorting** — table sorted by `created_at` descending - **Table columns** — changed from `[Fingerprint, Source, Account, Status, Created, Actions]` to `[ID, Account, Source, Date Range, Status, Retries, Created, Actions]` - **Retry count display** — shows `retry_count/RETRY_MAX` when >0, otherwise `—` - **Status tooltip** — shows `error_message` on hover when present - **Status icons** — new `statusIcons` map: ScheduleIcon (pending), CircularProgress (processing), WarningAmber (paused), HourglassEmpty (raw_expenses_done/enriched_done), CheckCircle (completed), ErrorIcon (failed) - **Error handling** — 409 conflict detection (duplicate fingerprint), 422 validation via `formatApiError()` - **`handleRetry`** — PATCH `{status: "pending"}` on failed requests, success/error snackbar ### `src/features/fetch-requests/fetch-requests.models.ts` **(+97 lines)** New types and helpers: - Added `"paused"` to `FetchRequestStatus` - `FileSource`: added `raw_lines`, `txn_blocks`, `txn_dicts`, `txn_dict_count`, `txn_dicts_count` - `EmailSource`: added `txn_dict_count`, `txn_dicts_count` - `FetchRequest` added `retry_count` - **New interfaces**: `FetchRequestUpdate`, `AmbiguityCandidate`, `PendingAmbiguity`, `ResolveAmbiguityPayload`, `FetchRequestFilters` - **SSE types**: - `SSEEventStep`: `load_content | raw_lines | txn_blocks | txn_dicts | resume_extract | extract | paused | complete | enrich | save_expenses | pipeline` - `SSEEventStatus`: `started | completed | skipped | paused | progress | failed` - `ProgressMessage`: `lines? | blocks? | count? | unit? | raw_ocr_line? | error?` - `SSEEvent: { step, status, message }` - **Helpers**: `formatApiError()` — parses FastAPI 422 validation detail arrays (`"Missing: field_name"`), `RETRY_MAX = 3` ### `src/features/fetch-requests/index.ts` **(+13 lines)** Barrel exports for all new types (`FetchRequestUpdate`, `FetchRequestFilters`, `PendingAmbiguity`, `AmbiguityCandidate`, `ResolveAmbiguityPayload`, `SSEEvent`, `SSEEventStep`, `SSEEventStatus`, `ProgressMessage`), value exports (`RETRY_MAX`, `formatApiError`), and new hooks (`useUpdateFetchRequest`, `useFetchRequestAmbiguities`, `useResolveAmbiguity`) ### `src/features/fetch-requests/useFetchRequests.ts` **(+49 lines)** Added hooks: - `useUpdateFetchRequest()` → `usePatch("fetch-requests")` - `useFetchRequestAmbiguities(fetchRequestId)` → `useQuery` for `GET /fetch-requests/{id}/ambiguities` - `useResolveAmbiguity()` → `useMutation` for `POST /ambiguities/{id}/resolve` with cache invalidation of both ambiguities and detail queries ### `src/main.jsx` **(+2 lines)** - Added import for `FetchRequestDetail` - Added route `{ path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" }` Reviewed-on: https://git.aetoskia.com/apps/khata-ui/pulls/9 Co-authored-by: Vishesh 'ironeagle' Bangotra Co-committed-by: Vishesh 'ironeagle' Bangotra --- react-openapi/api/client.ts | 4 + react-openapi/hooks/useResource.ts | 18 + src/FetchRequestDetail.tsx | 675 ++++++++++++++++++ src/FetchRequests.tsx | 347 +++++++-- .../fetch-requests/fetch-requests.models.ts | 97 ++- src/features/fetch-requests/index.ts | 13 + .../fetch-requests/useFetchRequests.ts | 49 +- src/main.jsx | 2 + 8 files changed, 1132 insertions(+), 73 deletions(-) create mode 100644 src/FetchRequestDetail.tsx diff --git a/react-openapi/api/client.ts b/react-openapi/api/client.ts index 97abc9e..d60f5d9 100644 --- a/react-openapi/api/client.ts +++ b/react-openapi/api/client.ts @@ -46,6 +46,10 @@ export const api = { if (!_api) throw new Error("API client not initialized"); return _api.delete(...args); }, + patch: (...args: Parameters) => { + if (!_api) throw new Error("API client not initialized"); + return _api.patch(...args); + }, }; export const auth = { diff --git a/react-openapi/hooks/useResource.ts b/react-openapi/hooks/useResource.ts index 1d6c388..f2a2a21 100644 --- a/react-openapi/hooks/useResource.ts +++ b/react-openapi/hooks/useResource.ts @@ -73,6 +73,23 @@ export function useResource(config: ResourceConfig | undefined) { }, }); + // --- PATCH --- + const usePatch = () => + useMutation({ + mutationFn: async ({ id, data }: { id: string; data: Partial }) => { + if (!endpoint) throw new Error("Endpoint not defined"); + // @ts-ignore + const res = await api.patch(`${endpoint}/${id}`, data); + return res.data; + }, + onSuccess: (updatedItem) => { + // @ts-ignore + const id = updatedItem[primaryKey]; + queryClient.invalidateQueries({ queryKey: [name, "list"] }); + queryClient.invalidateQueries({ queryKey: [name, "detail", id] }); + }, + }); + // --- DELETE --- const useDelete = () => useMutation({ @@ -136,6 +153,7 @@ export function useResource(config: ResourceConfig | undefined) { useMe, useCreate, useUpdate, + usePatch, useUpdateMe, useDelete, getListQueryOptions, diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx new file mode 100644 index 0000000..b55ad77 --- /dev/null +++ b/src/FetchRequestDetail.tsx @@ -0,0 +1,675 @@ +import * as React from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + Box, + Container, + Paper, + Typography, + Button, + Chip, + CircularProgress, + Alert, + Stepper, + Step, + StepLabel, + StepIcon, + LinearProgress, + IconButton, + Snackbar, +} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import ReplayIcon from "@mui/icons-material/Replay"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import ErrorIcon from "@mui/icons-material/Error"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; +import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; +import { + useFetchRequest, + useUpdateFetchRequest, + useFetchRequestAmbiguities, + useResolveAmbiguity, +} from "./features/fetch-requests"; +import type { + FetchRequestStatus, + SSEEvent, + ProgressMessage, +} from "./features/fetch-requests"; +import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; +import { useConfig } from "../react-openapi"; + +const statusColors: Record = { + pending: "default", + processing: "info", + paused: "warning", + raw_expenses_done: "primary", + enriched_done: "warning", + completed: "success", + failed: "error", +}; + +const statusIcons: Record = { + pending: , + processing: , + paused: , + raw_expenses_done: , + enriched_done: , + completed: , + failed: , +}; + +function computeProgressPercent( + status: FetchRequestStatus, + liveCount: number, + seenSteps: Set, + stepStats: Record, + txnBlockCount: number, + txnDictCount: number, +): number { + if (status === "pending") return 0; + if (status === "completed") return 100; + + let pct = 0; + + if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10; + + if (txnBlockCount > 0) { + const current = Math.max(liveCount, stepStats.txn_dicts ?? 0); + pct += Math.min(1, current / txnBlockCount) * 20; + } + + if (txnDictCount > 0) { + pct += Math.min(1, (stepStats.enrich_count ?? 0) / txnDictCount) * 50; + pct += Math.min(1, (stepStats.save_count ?? 0) / txnDictCount) * 20; + } + + return Math.round(Math.min(100, pct)); +} + +const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"]; + +function computeActiveStep(status: FetchRequestStatus, seenSteps: Set): number { + if (status === "completed") return stepLabels.length; + + if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) return stepLabels.length; + if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 3; + + if (seenSteps.has("enrich/completed")) return 3; + if (seenSteps.has("enrich")) return 2; + + if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") return 2; + if (seenSteps.has("txn_dicts")) return 1; + + if (seenSteps.has("txn_blocks/completed")) return 1; + if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) return 0; + + if (status === "processing" || status === "paused") return 0; + + return -1; +} + +function formatProgressMessage(msg: ProgressMessage): string { + if (msg.lines !== undefined) return `${msg.lines} lines`; + if (msg.blocks !== undefined) return `${msg.blocks} blocks`; + if (msg.count !== undefined && msg.unit) return `${msg.count} ${msg.unit}`; + if (msg.count !== undefined) return `${msg.count} items`; + if (msg.raw_ocr_line) return `"${msg.raw_ocr_line.slice(0, 60)}${msg.raw_ocr_line.length > 60 ? "…" : ""}"`; + if (msg.error) return msg.error.slice(0, 80); + return ""; +} + +function sseIcon(status: SSEEvent["status"]) { + switch (status) { + case "started": return ; + case "completed": return ; + case "failed": return ; + case "skipped": return ; + case "paused": return ; + case "progress": return ( + + ); + } +} + +function isMathValid(candidate: { amount: number; balance: number }, prevBalance: number) { + return ( + candidate.balance === prevBalance + candidate.amount || + candidate.balance === prevBalance - candidate.amount || + Math.abs(candidate.balance - (prevBalance + candidate.amount)) < 0.01 || + Math.abs(candidate.balance - (prevBalance - candidate.amount)) < 0.01 + ); +} + +export default function FetchRequestDetail() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const config = useConfig(); + + const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useFetchRequest(id!); + const updateMutation = useUpdateFetchRequest(); + const resolveMutation = useResolveAmbiguity(); + const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!); + + const [sseEvents, setSseEvents] = React.useState([]); + const [sseConnected, setSseConnected] = React.useState(false); + const [liveParsedCount, setLiveParsedCount] = React.useState(undefined); + const [stepStats, setStepStats] = React.useState>({}); + const [failNotif, setFailNotif] = React.useState(null); + const sseRef = React.useRef(null); + const feedRef = React.useRef(null); + + const txnBlockCount = React.useMemo(() => { + const blocks = (fetchRequest as any)?.source?.txn_blocks; + if (!blocks) return 0; + return Object.values(blocks).reduce( + (sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0), + 0, + ); + }, [fetchRequest]); + + const stepMessages = React.useMemo(() => { + const msgs: Record = {}; + const source = (fetchRequest as any)?.source; + + const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0); + if (rawLineCount) msgs[0] = `${rawLineCount}`; + + const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0; + const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0; + const dictCurrent = Math.max(dictLive, sourceDictCount); + if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`; + else if (dictCurrent) msgs[1] = `${dictCurrent}`; + + const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount; + if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`; + else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`; + + if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`; + else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`; + + return msgs; + }, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]); + + React.useEffect(() => { + if (!id || !config?.baseUrl) return; + const url = `${config.baseUrl}/fetch-requests/${id}/events`; + const es = new EventSource(url); + sseRef.current = es; + + es.onopen = () => setSseConnected(true); + es.onerror = () => setSseConnected(false); + es.onmessage = (event) => { + try { + const parsed: SSEEvent = JSON.parse(event.data); + setSseEvents((prev) => [...prev, parsed]); + + if (parsed.status === "progress" && parsed.message.count !== undefined) { + if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count); + if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! })); + if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! })); + } + + if (parsed.status === "completed" && parsed.message.count !== undefined) { + const stats: Record = {}; + if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines; + if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks; + if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count; + if (parsed.step === "enrich") stats.enrich_count = parsed.message.count; + if (parsed.step === "save_expenses") stats.save_count = parsed.message.count; + if (Object.keys(stats).length) { + setStepStats((prev) => ({ ...prev, ...stats })); + } + } + + if (parsed.status === "paused") { + refetchRequest(); + refetchAmbiguities(); + } + if (parsed.status === "failed") { + setFailNotif(parsed.message.error || "Fetch request failed"); + refetchRequest(); + } + if (parsed.status === "completed" || parsed.step === "resume_extract") { + refetchRequest(); + } + } catch { + // ignore malformed events + } + }; + + return () => { + es.close(); + sseRef.current = null; + }; + }, [id, config?.baseUrl]); + + React.useEffect(() => { + if (feedRef.current) { + feedRef.current.scrollTop = feedRef.current.scrollHeight; + } + }, [sseEvents]); + + const displayEvents = React.useMemo(() => { + const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]); + const lastProgressIdx: Record = {}; + for (let i = sseEvents.length - 1; i >= 0; i--) { + const e = sseEvents[i]; + if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) { + lastProgressIdx[e.step] = i; + } + } + + const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]); + return sseEvents.filter((e, i) => { + if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step]; + if (e.status === "started") { + return !sseEvents.slice(i + 1).some( + (later) => later.step === e.step && terminalStatuses.has(later.status), + ); + } + return true; + }); + }, [sseEvents]); + + const seenSteps = React.useMemo(() => { + const steps = new Set(); + for (const evt of sseEvents) { + steps.add(evt.step); + if (evt.status === "completed") steps.add(`${evt.step}/completed`); + if (evt.status === "failed") steps.add(`${evt.step}/failed`); + if (evt.status === "started") steps.add(`${evt.step}/started`); + if (evt.status === "progress") steps.add(`${evt.step}/progress`); + } + return steps; + }, [sseEvents]); + + const displayParsedCount = React.useMemo(() => { + if (liveParsedCount && liveParsedCount > 0) return liveParsedCount; + const source = (fetchRequest as any)?.source; + const persistedCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0; + if (persistedCount > 0) return persistedCount; + const dicts = source?.txn_dicts; + if (Array.isArray(dicts) && dicts.length > 0) return dicts.length; + return 0; + }, [liveParsedCount, fetchRequest]); + + const txnDictCount = React.useMemo(() => { + const source = (fetchRequest as any)?.source; + if (stepStats.txn_dicts && stepStats.txn_dicts > 0) return stepStats.txn_dicts; + return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0; + }, [fetchRequest, stepStats]); + + const progressPercent = React.useMemo( + () => computeProgressPercent( + (fetchRequest as any)?.status as FetchRequestStatus ?? "pending", + displayParsedCount, + seenSteps, + stepStats, + txnBlockCount, + txnDictCount, + ), + [fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount], + ); + + const handleRetry = async () => { + if (!id) return; + try { + await updateMutation.mutateAsync({ id, data: { status: "pending" } }); + } catch (err: any) { + setFailNotif(formatApiError(err)); + } + }; + + const handleResolve = async (ambiguity: any, candidate: { amount: number; balance: number }) => { + await resolveMutation.mutateAsync({ + ambiguityId: ambiguity.id, + payload: { chosen: { amount: candidate.amount, balance: candidate.balance } }, + }); + refetchAmbiguities(); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (fetchError || !fetchRequest) { + return ( + + + Failed to load fetch request + + ); + } + + const req = fetchRequest as any; + const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps); + const retryCount = req.retry_count ?? 0; + const isRetryExhausted = retryCount >= RETRY_MAX; + const pendingAmbiguities = ambiguities?.filter((a: any) => a.status === "pending") ?? []; + const resolvedAmbiguities = ambiguities?.filter((a: any) => a.status === "resolved") ?? []; + const hasAmbiguities = ambiguities && ambiguities.length > 0; + const allResolved = hasAmbiguities && pendingAmbiguities.length === 0; + const ambiguitiesLoading = !ambiguities; + + return ( + + + + + + + {req.account_name} + + + + + + Date Range + + {(req as any).start_date ? new Date((req as any).start_date).toLocaleDateString() : "?"} → {(req as any).end_date ? new Date((req as any).end_date).toLocaleDateString() : "?"} + + + + Created + {new Date(req.created_at).toLocaleString()} + + {req.completed_at && ( + + Completed + {new Date(req.completed_at).toLocaleString()} + + )} + + + + + + Overall Progress + + {["processing", "paused"].includes(req.status) && displayParsedCount > 0 && ( + + Validated: {displayParsedCount} transactions + + )} + + + + {progressPercent}% + + + + + + + Retries: {retryCount}/{RETRY_MAX} + + + + {req.status === "failed" && !isRetryExhausted && ( + + )} + + + + {req.status === "failed" && req.error_message && ( + + {req.error_message} + + )} + + {isRetryExhausted && req.status === "failed" && ( + + Max retries reached — no further retry attempts will be made. + + )} + + + + Pipeline Progress + + + {stepLabels.map((label, index) => { + const isCompleted = index < activeStep; + const isActive = index === activeStep; + const isPaused = req.status === "paused" && isActive; + const isFailed = req.status === "failed" && isActive; + + let icon: React.ReactNode; + if (isCompleted) { + icon = ; + } else if (isFailed) { + icon = ; + } else if (isPaused) { + icon = ; + } else if (isActive) { + icon = ; + } else { + icon = {index + 1}; + } + + const stepMsg = stepMessages[index]; + + return ( + + {icon}} + > + {label} + {stepMsg && ( + + {stepMsg} + + )} + + + ); + })} + + + + + + + Progress Events + + + + {sseConnected ? "Connected" : "Disconnected"} + + + + {displayEvents.length === 0 ? ( + + Waiting for events... + + ) : ( + displayEvents.map((evt, i) => ( + + {sseIcon(evt.status)} + + + {evt.step.replace(/_/g, " ")} + + {evt.message && formatProgressMessage(evt.message) && ( + + {formatProgressMessage(evt.message)} + + )} + + + {new Date().toLocaleTimeString()} + + + )) + )} + + + + {hasAmbiguities && ( + + + Ambiguity Resolution + + + {allResolved ? ( + + All ambiguities resolved — pipeline will resume on next poll cycle + + ) : ( + + Pipeline paused — resolve ambiguities to continue + + )} + + + {ambiguities.map((ambiguity: any) => { + const isResolved = ambiguity.status === "resolved"; + return ( + + + {ambiguity.line} + + + + + OCR Amount + + ₹{ambiguity.ocr_amount} + + + + OCR Balance + + ₹{ambiguity.ocr_balance} + + + + Previous Balance + ₹{ambiguity.prev_balance} + + + + {isResolved ? ( + }> + Resolved: ₹{ambiguity.chosen?.amount} / ₹{ambiguity.chosen?.balance} + + ) : ( + + {ambiguity.candidates.map((candidate: any, ci: number) => { + const isCredit = candidate.amount > 0; + const isDebit = candidate.amount < 0; + const cColor = isCredit ? "success.main" : isDebit ? "error.main" : undefined; + return ( + + ); + })} + + )} + + ); + })} + + + )} + setFailNotif(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setFailNotif(null)} sx={{ borderRadius: 2 }}> + {failNotif} + + + + ); +} diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx index 9a21e6a..d1f5c8c 100644 --- a/src/FetchRequests.tsx +++ b/src/FetchRequests.tsx @@ -24,14 +24,28 @@ import { DialogContent, DialogContentText, DialogActions, + Tooltip, + Select, + MenuItem, + InputLabel, + FormControl, + OutlinedInput, + Autocomplete, } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import RefreshIcon from "@mui/icons-material/Refresh"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import ReplayIcon from "@mui/icons-material/Replay"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import ErrorIcon from "@mui/icons-material/Error"; +import ScheduleIcon from "@mui/icons-material/Schedule"; +import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty"; +import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import { useFetchRequestsList, useCreateFetchRequest, + useUpdateFetchRequest, useDeleteFetchRequest, useUploadFile, } from "./features/fetch-requests"; @@ -41,22 +55,49 @@ import type { FileSource, EmailSource, } from "./features/fetch-requests"; +import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; +import { useNavigate } from "react-router-dom"; +import { useResourceByName, useConfig } from "../react-openapi"; const statusColors: Record = { pending: "default", processing: "info", + paused: "warning", raw_expenses_done: "primary", enriched_done: "warning", completed: "success", failed: "error", }; +const statusIcons: Record = { + pending: , + processing: , + paused: , + raw_expenses_done: , + enriched_done: , + completed: , + failed: , +}; + function formatDate(iso: string) { const d = new Date(iso); return d.toLocaleString(); } +function formatDateRange(start?: string, end?: string) { + if (!start && !end) return "—"; + const s = start ? new Date(start).toLocaleDateString() : "?"; + const e = end ? new Date(end).toLocaleDateString() : "?"; + return `${s} → ${e}`; +} + +function shortId(fp: string) { + return fp.length > 8 ? fp.slice(0, 8) + "…" : fp; +} + export default function FetchRequests() { + const navigate = useNavigate(); + const [sourceType, setSourceType] = React.useState<"file" | "email">("file"); const [accountName, setAccountName] = React.useState(""); const [payorUsername, setPayorUsername] = React.useState("aetos"); @@ -71,8 +112,27 @@ export default function FetchRequests() { const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null); const [deleteTarget, setDeleteTarget] = React.useState(null); - const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList(); + const [statusFilter, setStatusFilter] = React.useState([]); + const [accountFilter, setAccountFilter] = React.useState(""); + const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all"); + + const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList({ + ...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}), + ...(accountFilter ? { account_name: accountFilter } : {}), + ...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}), + }); + const { useList: useAccountsList } = useResourceByName("accounts"); + const { data: accountsData } = useAccountsList(); + const accountOptions: string[] = React.useMemo(() => { + return (accountsData?.data ?? []).map((a: any) => a.name).filter(Boolean); + }, [accountsData]); + + const config = useConfig(); + const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests"); + const formatOptions: string[] = (fetchRes?.fields?.source?.schema?.format?.options as string[]) ?? ["axis", "icici_ocr"]; + const createMutation = useCreateFetchRequest(); + const updateMutation = useUpdateFetchRequest(); const deleteMutation = useDeleteFetchRequest(); const uploadMutation = useUploadFile(); @@ -106,7 +166,7 @@ export default function FetchRequests() { } try { - await createMutation.mutateAsync({ + const result = await createMutation.mutateAsync({ source, account_name: accountName, payor_username: payorUsername, @@ -115,8 +175,13 @@ export default function FetchRequests() { }); setSnackbar({ message: "Fetch request created", severity: "success" }); resetForm(); + navigate(`/fetch-requests/${result.id}`); } catch (err: any) { - setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" }); + if (err?.response?.status === 409) { + setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" }); + } else { + setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" }); + } } }; @@ -132,6 +197,15 @@ export default function FetchRequests() { setEndDate(""); }; + const handleRetry = async (req: FetchRequest) => { + try { + await updateMutation.mutateAsync({ id: req.id, data: { status: "pending" } }); + setSnackbar({ message: "Retrying fetch request", severity: "success" }); + } catch { + setSnackbar({ message: "Failed to retry", severity: "error" }); + } + }; + const handleDelete = async () => { if (!deleteTarget) return; try { @@ -143,6 +217,8 @@ export default function FetchRequests() { setDeleteTarget(null); }; + const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"]; + return ( @@ -189,37 +265,61 @@ export default function FetchRequests() { Uploaded as: {uploadedPath} )} - setFormat(e.target.value)} size="small" /> + + Format + + ) : ( <> - setFormat(e.target.value)} size="small" helperText="e.g. email, pdf, csv" /> + + Format + + setFromEmail(e.target.value)} size="small" /> setSubject(e.target.value)} size="small" /> setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" /> )} - setAccountName(e.target.value)} size="small" required /> + setAccountName(val ?? "")} + renderInput={(params) => ( + + )} + sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }} + /> setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" /> setStartDate(e.target.value)} size="small" InputLabelProps={{ shrink: true }} + inputProps={{ max: new Date().toISOString().split("T")[0] }} sx={{ flex: 1 }} /> setEndDate(e.target.value)} size="small" InputLabelProps={{ shrink: true }} + inputProps={{ max: new Date().toISOString().split("T")[0] }} sx={{ flex: 1 }} /> @@ -234,79 +334,184 @@ export default function FetchRequests() { - - - - Fetch Requests - + + + + Status + + + setAccountFilter(e.target.value)} + size="small" + sx={{ minWidth: 160 }} + /> + val && setSourceFilter(val)} + size="small" + > + {sourceTypeOptions.map((opt) => ( + + {opt === "all" ? "All" : opt === "file" ? "File" : "Email"} + + ))} + + refetch()} disabled={isFetching}> + - {isLoading ? ( - - - - ) : requests.length === 0 ? ( - - No fetch requests yet - - ) : ( - - - - - Fingerprint - Source - Account - Status - Created - Actions - - - - {requests.map((req: FetchRequest) => ( - - - - {req.fingerprint} + {isLoading ? ( + + + + ) : requests.length === 0 ? ( + + No fetch requests yet + + ) : ( + +
+ + + ID + Account + Source + Date Range + Status + Retries + Created + Actions + + + + {[...requests] + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((req: FetchRequest) => ( + navigate(`/fetch-requests/${req.id}`)} + sx={{ cursor: "pointer", "&:last-child td": { border: 0 } }} + > + + + {shortId(req.fingerprint)} + { + e.stopPropagation(); + navigator.clipboard.writeText(req.fingerprint); + setSnackbar({ message: "Copied!", severity: "success" }); + }} + sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }} + > + + + + + {req.account_name} + + + + + + {formatDateRange((req as any).start_date, (req as any).end_date)} + + + + + + + + + + + {(req.retry_count ?? 0) > 0 ? ( + + {req.retry_count}/{RETRY_MAX} + + ) : ( + + — + + )} + + + {formatDate(req.created_at)} + + + + {req.status === "paused" && ( + + { + e.stopPropagation(); + navigate(`/fetch-requests/${req.id}`); + }} + > + + + + )} + {req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && ( + + { + e.stopPropagation(); + handleRetry(req); + }} + > + + + + )} + { - navigator.clipboard.writeText(req.fingerprint); - setSnackbar({ message: "Copied!", severity: "success" }); + onClick={(e) => { + e.stopPropagation(); + setDeleteTarget(req); }} - sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }} > - + - - - - {"path" in req.source ? "File" : "Email"} - - {req.account_name} - - - - {formatDate(req.created_at)} - - setDeleteTarget(req)}> - - - - - ))} - -
-
- )} -
+ + + + + ))} + + + + )} ; + txn_dicts?: Record[]; + txn_dict_count?: number; + txn_dicts_count?: number; } export interface EmailSource { @@ -10,6 +22,8 @@ export interface EmailSource { from_email?: string; subject?: string; raw_terms?: string[]; + txn_dict_count?: number; + txn_dicts_count?: number; } export interface FetchRequestCreate { @@ -20,12 +34,18 @@ export interface FetchRequestCreate { end_date?: string; } +export interface FetchRequestUpdate { + status?: FetchRequestStatus; + error_message?: string | null; +} + export interface FetchRequest extends FetchRequestCreate { id: string; status: FetchRequestStatus; fingerprint: string; completed_at?: string | null; error_message?: string | null; + retry_count?: number; created_at: string; } @@ -36,3 +56,78 @@ export interface UploadResult { url: string; absolute_path: string; } + +export interface AmbiguityCandidate { + amount: number; + balance: number; +} + +export interface PendingAmbiguity { + id: string; + fetch_request: string; + step_index?: number; + line: string; + ocr_amount: number; + ocr_balance: number; + prev_balance: number; + candidates: AmbiguityCandidate[]; + chosen?: AmbiguityCandidate | null; + resolved_at?: string | null; + status: "pending" | "resolved"; + created_at: string; +} + +export interface ResolveAmbiguityPayload { + chosen: { + amount: number; + balance: number; + }; +} + +export type SSEEventStep = + | "load_content" | "raw_lines" | "txn_blocks" | "txn_dicts" + | "resume_extract" | "extract" | "paused" | "complete" | "enrich" + | "save_expenses" | "pipeline"; + +export type SSEEventStatus = + | "started" | "completed" | "skipped" | "paused" | "progress" | "failed"; + +export interface ProgressMessage { + lines?: number; + blocks?: number; + count?: number; + unit?: string; + raw_ocr_line?: string; + error?: string; +} + +export interface SSEEvent { + step: SSEEventStep; + status: SSEEventStatus; + message: ProgressMessage; +} + +export interface FetchRequestFilters { + status?: FetchRequestStatus[]; + account_name?: string; + 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; diff --git a/src/features/fetch-requests/index.ts b/src/features/fetch-requests/index.ts index 0af66e1..cd21072 100644 --- a/src/features/fetch-requests/index.ts +++ b/src/features/fetch-requests/index.ts @@ -1,15 +1,28 @@ export type { FetchRequest, FetchRequestCreate, + FetchRequestUpdate, FetchRequestStatus, + FetchRequestFilters, FileSource, EmailSource, UploadResult, + PendingAmbiguity, + AmbiguityCandidate, + ResolveAmbiguityPayload, + SSEEvent, + SSEEventStep, + SSEEventStatus, + ProgressMessage, } from "./fetch-requests.models"; +export { RETRY_MAX, formatApiError } from "./fetch-requests.models"; export { useFetchRequestsList, useFetchRequest, useCreateFetchRequest, + useUpdateFetchRequest, useDeleteFetchRequest, useUploadFile, + useFetchRequestAmbiguities, + useResolveAmbiguity, } from "./useFetchRequests"; diff --git a/src/features/fetch-requests/useFetchRequests.ts b/src/features/fetch-requests/useFetchRequests.ts index 7b20ccc..572d182 100644 --- a/src/features/fetch-requests/useFetchRequests.ts +++ b/src/features/fetch-requests/useFetchRequests.ts @@ -1,6 +1,7 @@ import { useResourceByName } from "../../../react-openapi"; import { api } from "../../../react-openapi/api/client"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ResolveAmbiguityPayload } from "./fetch-requests.models"; export function useFetchRequestsList(params?: { status?: string; @@ -21,6 +22,11 @@ export function useCreateFetchRequest() { return useCreate(); } +export function useUpdateFetchRequest() { + const { usePatch } = useResourceByName("fetch-requests"); + return usePatch(); +} + export function useDeleteFetchRequest() { const { useDelete } = useResourceByName("fetch-requests"); return useDelete(); @@ -41,3 +47,44 @@ export function useUploadFile() { }, }); } + +export function useFetchRequestAmbiguities(fetchRequestId: string) { + return useQuery({ + queryKey: ["fetch-requests", fetchRequestId, "ambiguities"], + queryFn: async () => { + const res = await api.get( + `/fetch-requests/${fetchRequestId}/ambiguities` + ); + return res.data; + }, + enabled: !!fetchRequestId, + }); +} + +export function useResolveAmbiguity() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + ambiguityId, + payload, + }: { + ambiguityId: string; + payload: ResolveAmbiguityPayload; + }) => { + const res = await api.post( + `/ambiguities/${ambiguityId}/resolve`, + payload + ); + return res.data; + }, + onSuccess: (data: any) => { + queryClient.invalidateQueries({ + queryKey: ["fetch-requests", data.fetch_request, "ambiguities"], + }); + queryClient.invalidateQueries({ + queryKey: ["fetch-requests", "detail", data.fetch_request], + }); + }, + }); +} diff --git a/src/main.jsx b/src/main.jsx index 0b80d58..9460733 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -13,6 +13,7 @@ import { import Home from './Home'; import Dashboard from './Dashboard'; import FetchRequests from './FetchRequests'; +import FetchRequestDetail from './FetchRequestDetail'; import ReportSnapshots from './ReportSnapshots'; import { Admin, AppProvider } from '../react-openapi'; import { configuration, profileConfiguration } from './openapi-config'; @@ -36,6 +37,7 @@ const routerMapping = [ { path: "/home", component: Home, headerTitle: "Home" }, { path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" }, { path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" }, + { path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" }, { path: "/reports", component: ReportSnapshots, headerTitle: "Reports" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" }, ];