From c920276293f613503faaa9b505104519978903d9 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 28 May 2026 17:52:53 +0530 Subject: [PATCH 01/16] fetch request steps --- react-openapi/api/client.ts | 4 + react-openapi/hooks/useResource.ts | 18 + src/FetchRequestDetail.tsx | 448 ++++++++++++++++++ src/FetchRequests.tsx | 288 ++++++++--- .../fetch-requests/fetch-requests.models.ts | 56 ++- src/features/fetch-requests/index.ts | 10 + .../fetch-requests/useFetchRequests.ts | 49 +- src/main.jsx | 2 + 8 files changed, 807 insertions(+), 68 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..d16959d --- /dev/null +++ b/src/FetchRequestDetail.tsx @@ -0,0 +1,448 @@ +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, +} 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 { + useFetchRequest, + useUpdateFetchRequest, + useFetchRequestAmbiguities, + useResolveAmbiguity, +} from "./features/fetch-requests"; +import type { + FetchRequestStatus, + SSEEvent, +} from "./features/fetch-requests"; +import { RETRY_MAX } 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: , +}; + +const stepLabels = ["Load Content", "Extract", "Enrich", "Save"]; + +function statusToActiveStep(status: FetchRequestStatus): number { + switch (status) { + case "pending": return -1; + case "processing": return 0; + case "paused": return 1; + case "raw_expenses_done": return 2; + case "enriched_done": return 3; + case "completed": return 4; + case "failed": return 0; + default: return -1; + } +} + +function sseIcon(status: SSEEvent["status"]) { + switch (status) { + case "started": return ; + case "completed": return ; + case "paused": 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 } = 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 sseRef = React.useRef(null); + const feedRef = React.useRef(null); + + 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]); + } 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 handleRetry = async () => { + if (!id) return; + try { + await updateMutation.mutateAsync({ id, data: { status: "pending" } }); + } catch { + // handled by react query + } + }; + + 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 = statusToActiveStep(req.status); + 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; + + 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()} + + )} + + + + + + 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; + + let icon: React.ReactNode; + if (isCompleted) { + icon = ; + } else if (isPaused) { + icon = ; + } else if (isActive) { + icon = ; + } else { + icon = {index + 1}; + } + + return ( + + {icon}} + > + {label} + + + ); + })} + + + + + + + Progress Events + + + + {sseConnected ? "Connected" : "Disconnected"} + + + + {sseEvents.length === 0 ? ( + + Waiting for events... + + ) : ( + sseEvents.map((evt, i) => ( + + {sseIcon(evt.status)} + + + {evt.step.replace(/_/g, " ")} + + {evt.message && ( + + {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 valid = isMathValid(candidate, ambiguity.prev_balance); + return ( + + ); + })} + + )} + + ); + })} + + + )} + + ); +} diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx index 9a21e6a..44add79 100644 --- a/src/FetchRequests.tsx +++ b/src/FetchRequests.tsx @@ -24,14 +24,27 @@ import { DialogContent, DialogContentText, DialogActions, + Tooltip, + Select, + MenuItem, + InputLabel, + FormControl, + OutlinedInput, } 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 +54,48 @@ import type { FileSource, EmailSource, } from "./features/fetch-requests"; +import { RETRY_MAX } from "./features/fetch-requests"; +import { useNavigate } from "react-router-dom"; 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 +110,17 @@ 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 createMutation = useCreateFetchRequest(); + const updateMutation = useUpdateFetchRequest(); const deleteMutation = useDeleteFetchRequest(); const uploadMutation = useUploadFile(); @@ -106,7 +154,7 @@ export default function FetchRequests() { } try { - await createMutation.mutateAsync({ + const result = await createMutation.mutateAsync({ source, account_name: accountName, payor_username: payorUsername, @@ -115,8 +163,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: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" }); + } } }; @@ -132,6 +185,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 +205,8 @@ export default function FetchRequests() { setDeleteTarget(null); }; + const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"]; + return ( @@ -234,79 +298,171 @@ 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 === "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)}> - - - - - ))} - -
-
- )} -
+ + + + + ))} + + + + )} { + 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" }, ]; -- 2.49.1 From ecdfc2094e8b3c1e4a5cf33456eee54dc72a831c Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 29 May 2026 15:05:18 +0530 Subject: [PATCH 02/16] fixes --- src/FetchRequestDetail.tsx | 3 +++ src/FetchRequests.tsx | 40 +++++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index d16959d..f33a47d 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -264,10 +264,13 @@ export default function FetchRequestDetail() { 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) { diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx index 44add79..be105d4 100644 --- a/src/FetchRequests.tsx +++ b/src/FetchRequests.tsx @@ -30,6 +30,7 @@ import { InputLabel, FormControl, OutlinedInput, + Autocomplete, } from "@mui/material"; import DeleteIcon from "@mui/icons-material/Delete"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; @@ -56,6 +57,7 @@ import type { } from "./features/fetch-requests"; import { RETRY_MAX } from "./features/fetch-requests"; import { useNavigate } from "react-router-dom"; +import { useResourceByName, useConfig } from "../react-openapi"; const statusColors: Record = { pending: "default", @@ -119,6 +121,16 @@ export default function FetchRequests() { ...(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(); @@ -253,18 +265,40 @@ 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" /> -- 2.49.1 From acbfca94f20679d60e4af59ac70b50a4e94d764d Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 29 May 2026 15:55:40 +0530 Subject: [PATCH 03/16] fixes --- src/FetchRequestDetail.tsx | 26 +++++++++++++++++++++----- src/FetchRequests.tsx | 19 +++++++++++++++++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index f33a47d..745d9e9 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -92,7 +92,7 @@ export default function FetchRequestDetail() { const navigate = useNavigate(); const config = useConfig(); - const { data: fetchRequest, isLoading, error: fetchError } = useFetchRequest(id!); + const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useFetchRequest(id!); const updateMutation = useUpdateFetchRequest(); const resolveMutation = useResolveAmbiguity(); const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!); @@ -114,6 +114,10 @@ export default function FetchRequestDetail() { try { const parsed: SSEEvent = JSON.parse(event.data); setSseEvents((prev) => [...prev, parsed]); + if (parsed.status === "paused") { + refetchRequest(); + refetchAmbiguities(); + } } catch { // ignore malformed events } @@ -175,6 +179,7 @@ export default function FetchRequestDetail() { 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 ( @@ -358,22 +363,32 @@ export default function FetchRequestDetail() { - {hasAmbiguities && ( + {(hasAmbiguities || req.status === "paused") && ( Ambiguity Resolution - {allResolved ? ( + {ambiguitiesLoading ? ( + + + Loading ambiguities... + + ) : allResolved ? ( All ambiguities resolved — pipeline will resume on next poll cycle + ) : !hasAmbiguities ? ( + + Pipeline paused — no ambiguities found + ) : ( Pipeline paused — resolve ambiguities to continue )} + {hasAmbiguities && ( {ambiguities.map((ambiguity: any) => { const isResolved = ambiguity.status === "resolved"; @@ -444,8 +459,9 @@ export default function FetchRequestDetail() { ); })} - - )} + )} + + )}
); } diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx index be105d4..d63d204 100644 --- a/src/FetchRequests.tsx +++ b/src/FetchRequests.tsx @@ -304,20 +304,22 @@ export default function FetchRequests() { 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 }} /> @@ -465,6 +467,19 @@ export default function FetchRequests() { + {req.status === "paused" && ( + + { + e.stopPropagation(); + navigate(`/fetch-requests/${req.id}`); + }} + > + + + + )} {req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && ( Date: Fri, 29 May 2026 16:14:05 +0530 Subject: [PATCH 04/16] details for counts for steps --- src/FetchRequestDetail.tsx | 36 ++++++++++++++++++- .../fetch-requests/fetch-requests.models.ts | 3 ++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 745d9e9..a760c8a 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -97,6 +97,33 @@ export default function FetchRequestDetail() { const resolveMutation = useResolveAmbiguity(); const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!); + const stepMessages = React.useMemo(() => { + const msgs: Record = {}; + const source = (fetchRequest as any)?.source; + + if (source?.raw_lines?.length) + msgs[0] = `${source.raw_lines.length} raw lines`; + + const blocks = source?.txn_blocks ?? {}; + const dicts = source?.txn_dicts ?? []; + const blockCount = typeof blocks === "object" + ? Object.keys(blocks).length : Array.isArray(blocks) ? blocks.length : 0; + if (blockCount || dicts.length) { + const parts: string[] = []; + if (blockCount) parts.push(`${blockCount} blocks`); + if (dicts.length) parts.push(`${dicts.length} dicts`); + msgs[1] = parts.join(" · "); + } + + if (["enriched_done", "completed"].includes((fetchRequest as any)?.status)) + msgs[2] = "done"; + if ((fetchRequest as any)?.status === "completed") + msgs[3] = (fetchRequest as any)?.completed_at + ? new Date((fetchRequest as any).completed_at).toLocaleString() : "done"; + + return msgs; + }, [fetchRequest]); + const [sseEvents, setSseEvents] = React.useState([]); const [sseConnected, setSseConnected] = React.useState(false); const sseRef = React.useRef(null); @@ -284,12 +311,19 @@ export default function FetchRequestDetail() { icon = {index + 1}; } + const stepMsg = stepMessages[index]; + return ( {icon}} > - {label} + {label} + {stepMsg && ( + + {stepMsg} + + )} ); diff --git a/src/features/fetch-requests/fetch-requests.models.ts b/src/features/fetch-requests/fetch-requests.models.ts index ccf1ed7..e9aa600 100644 --- a/src/features/fetch-requests/fetch-requests.models.ts +++ b/src/features/fetch-requests/fetch-requests.models.ts @@ -10,6 +10,9 @@ export type FetchRequestStatus = export interface FileSource { path: string; format: string; + raw_lines?: string[]; + txn_blocks?: Record; + txn_dicts?: Record[]; } export interface EmailSource { -- 2.49.1 From 034e0ad29aee30848fde95a7db318762874ee12f Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 29 May 2026 16:19:59 +0530 Subject: [PATCH 05/16] ambiguity amount balance coloring --- src/FetchRequestDetail.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index a760c8a..f6374c3 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -445,13 +445,13 @@ export default function FetchRequestDetail() { OCR Amount - + ₹{ambiguity.ocr_amount} OCR Balance - + ₹{ambiguity.ocr_balance} @@ -468,7 +468,9 @@ export default function FetchRequestDetail() { ) : ( {ambiguity.candidates.map((candidate: any, ci: number) => { - const valid = isMathValid(candidate, ambiguity.prev_balance); + const isCredit = candidate.amount > 0; + const isDebit = candidate.amount < 0; + const cColor = isCredit ? "success.main" : isDebit ? "error.main" : undefined; return ( ); })} -- 2.49.1 From 08057f370c1db5f951ff71419fc7dc0b71e16fd0 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 29 May 2026 16:27:55 +0530 Subject: [PATCH 06/16] sse events --- src/FetchRequestDetail.tsx | 9 +++++++-- src/features/fetch-requests/fetch-requests.models.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index f6374c3..408380c 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -22,6 +22,7 @@ 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 { useFetchRequest, useUpdateFetchRequest, @@ -55,13 +56,13 @@ const statusIcons: Record = { failed: , }; -const stepLabels = ["Load Content", "Extract", "Enrich", "Save"]; +const stepLabels = ["Load Content", "Extract", "Validate", "Enrich", "Save"]; function statusToActiveStep(status: FetchRequestStatus): number { switch (status) { case "pending": return -1; case "processing": return 0; - case "paused": return 1; + case "paused": return 2; case "raw_expenses_done": return 2; case "enriched_done": return 3; case "completed": return 4; @@ -74,6 +75,7 @@ function sseIcon(status: SSEEvent["status"]) { switch (status) { case "started": return ; case "completed": return ; + case "skipped": return ; case "paused": return ; } } @@ -145,6 +147,9 @@ export default function FetchRequestDetail() { refetchRequest(); refetchAmbiguities(); } + if (parsed.status === "completed" || parsed.step === "resume_extract") { + refetchRequest(); + } } catch { // ignore malformed events } diff --git a/src/features/fetch-requests/fetch-requests.models.ts b/src/features/fetch-requests/fetch-requests.models.ts index e9aa600..9159ca7 100644 --- a/src/features/fetch-requests/fetch-requests.models.ts +++ b/src/features/fetch-requests/fetch-requests.models.ts @@ -82,7 +82,7 @@ export interface ResolveAmbiguityPayload { export interface SSEEvent { step: string; - status: "started" | "completed" | "paused"; + status: "started" | "completed" | "skipped" | "paused"; message: string; } -- 2.49.1 From baffd11a491131a3cc2f6c4192ee56b76833bc0d Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Fri, 29 May 2026 16:29:40 +0530 Subject: [PATCH 07/16] validate step for amibiguity resolution --- src/FetchRequestDetail.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 408380c..8592213 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -65,7 +65,7 @@ function statusToActiveStep(status: FetchRequestStatus): number { case "paused": return 2; case "raw_expenses_done": return 2; case "enriched_done": return 3; - case "completed": return 4; + case "completed": return stepLabels.length; case "failed": return 0; default: return -1; } @@ -110,17 +110,15 @@ export default function FetchRequestDetail() { const dicts = source?.txn_dicts ?? []; const blockCount = typeof blocks === "object" ? Object.keys(blocks).length : Array.isArray(blocks) ? blocks.length : 0; - if (blockCount || dicts.length) { - const parts: string[] = []; - if (blockCount) parts.push(`${blockCount} blocks`); - if (dicts.length) parts.push(`${dicts.length} dicts`); - msgs[1] = parts.join(" · "); - } + if (blockCount) + msgs[1] = `${blockCount} blocks`; + if (dicts.length) + msgs[2] = `${dicts.length} dicts`; if (["enriched_done", "completed"].includes((fetchRequest as any)?.status)) - msgs[2] = "done"; + msgs[3] = "done"; if ((fetchRequest as any)?.status === "completed") - msgs[3] = (fetchRequest as any)?.completed_at + msgs[4] = (fetchRequest as any)?.completed_at ? new Date((fetchRequest as any).completed_at).toLocaleString() : "done"; return msgs; -- 2.49.1 From cb7f20181ef4f71d9188400a88b29eab3288e677 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 30 May 2026 03:02:23 +0530 Subject: [PATCH 08/16] richer sses --- src/FetchRequestDetail.tsx | 173 ++++++++++++++---- .../fetch-requests/fetch-requests.models.ts | 23 ++- src/features/fetch-requests/index.ts | 3 + 3 files changed, 163 insertions(+), 36 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 8592213..70c771d 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -23,6 +23,7 @@ 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, @@ -32,6 +33,7 @@ import { import type { FetchRequestStatus, SSEEvent, + ProgressMessage, } from "./features/fetch-requests"; import { RETRY_MAX } from "./features/fetch-requests"; import { useConfig } from "../react-openapi"; @@ -56,6 +58,29 @@ const statusIcons: Record = { failed: , }; +function computeProgressPercent( + status: FetchRequestStatus, + liveCount: number, + seenSteps: Set, +): number { + if (status === "pending") return 0; + if (status === "completed") return 100; + + if (seenSteps.has("complete")) return 90; + if (seenSteps.has("enrich")) return 85; + if (seenSteps.has("txn_dicts/completed")) return 80; + + if (seenSteps.has("txn_dicts/started") && liveCount > 0) { + return Math.min(80, 35 + Math.min(liveCount, 300) / 300 * 45); + } + + if (seenSteps.has("txn_blocks")) return 35; + if (seenSteps.has("raw_lines")) return 25; + if (seenSteps.has("load_content")) return 15; + + return status === "processing" || status === "paused" ? 10 : 0; +} + const stepLabels = ["Load Content", "Extract", "Validate", "Enrich", "Save"]; function statusToActiveStep(status: FetchRequestStatus): number { @@ -71,12 +96,26 @@ function statusToActiveStep(status: FetchRequestStatus): number { } } +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 ? "…" : ""}"`; + return ""; +} + function sseIcon(status: SSEEvent["status"]) { switch (status) { case "started": return ; case "completed": return ; case "skipped": return ; case "paused": return ; + case "progress": return ( + + ); } } @@ -99,21 +138,25 @@ export default function FetchRequestDetail() { 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(0); + const [stepStats, setStepStats] = React.useState>({}); + const sseRef = React.useRef(null); + const feedRef = React.useRef(null); + const stepMessages = React.useMemo(() => { const msgs: Record = {}; const source = (fetchRequest as any)?.source; - if (source?.raw_lines?.length) - msgs[0] = `${source.raw_lines.length} raw lines`; + const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0); + if (rawLineCount) msgs[0] = `${rawLineCount} raw lines`; - const blocks = source?.txn_blocks ?? {}; - const dicts = source?.txn_dicts ?? []; - const blockCount = typeof blocks === "object" - ? Object.keys(blocks).length : Array.isArray(blocks) ? blocks.length : 0; - if (blockCount) - msgs[1] = `${blockCount} blocks`; - if (dicts.length) - msgs[2] = `${dicts.length} dicts`; + const blockCount = stepStats.txn_blocks ?? 0; + if (blockCount) msgs[1] = `${blockCount} blocks`; + + const dictCount = stepStats.txn_dicts ?? liveParsedCount ?? 0; + if (dictCount) msgs[2] = `${dictCount} dicts`; if (["enriched_done", "completed"].includes((fetchRequest as any)?.status)) msgs[3] = "done"; @@ -122,12 +165,7 @@ export default function FetchRequestDetail() { ? new Date((fetchRequest as any).completed_at).toLocaleString() : "done"; return msgs; - }, [fetchRequest]); - - const [sseEvents, setSseEvents] = React.useState([]); - const [sseConnected, setSseConnected] = React.useState(false); - const sseRef = React.useRef(null); - const feedRef = React.useRef(null); + }, [fetchRequest, stepStats, liveParsedCount]); React.useEffect(() => { if (!id || !config?.baseUrl) return; @@ -141,6 +179,21 @@ export default function FetchRequestDetail() { try { const parsed: SSEEvent = JSON.parse(event.data); setSseEvents((prev) => [...prev, parsed]); + + if (parsed.step === "txn_dicts" && parsed.status === "progress" && parsed.message.count !== undefined) { + setLiveParsedCount(parsed.message.count); + } + + if (parsed.status === "completed") { + 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" && parsed.message.count !== undefined) stats.txn_dicts = parsed.message.count; + if (Object.keys(stats).length) { + setStepStats((prev) => ({ ...prev, ...stats })); + } + } + if (parsed.status === "paused") { refetchRequest(); refetchAmbiguities(); @@ -165,6 +218,49 @@ export default function FetchRequestDetail() { } }, [sseEvents]); + const displayEvents = React.useMemo(() => { + let lastProgressIdx = -1; + for (let i = sseEvents.length - 1; i >= 0; i--) { + if (sseEvents[i].step === "txn_dicts" && sseEvents[i].status === "progress") { + lastProgressIdx = i; + break; + } + } + return sseEvents.filter((e, i) => { + if (e.step === "txn_dicts" && e.status === "progress") return i === lastProgressIdx; + 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 === "started") steps.add(`${evt.step}/started`); + if (evt.status === "progress") steps.add(`${evt.step}/progress`); + } + return steps; + }, [sseEvents]); + + const progressPercent = React.useMemo( + () => computeProgressPercent( + (fetchRequest as any)?.status as FetchRequestStatus ?? "pending", + liveParsedCount, + seenSteps, + ), + [fetchRequest, liveParsedCount, seenSteps], + ); + + const displayParsedCount = React.useMemo(() => { + if (liveParsedCount > 0) return liveParsedCount; + const source = (fetchRequest as any)?.source; + if (source?.txn_dicts_count) return source.txn_dicts_count; + const dicts = source?.txn_dicts; + if (Array.isArray(dicts) && dicts.length > 0) return dicts.length; + return 0; + }, [liveParsedCount, fetchRequest]); + const handleRetry = async () => { if (!id) return; try { @@ -252,6 +348,28 @@ export default function FetchRequestDetail() { )} + + + + Overall Progress + + {["processing", "paused"].includes(req.status) && displayParsedCount > 0 && ( + + Validated: {displayParsedCount} transactions + + )} + + + + {progressPercent}% + + + @@ -363,12 +481,12 @@ export default function FetchRequestDetail() { gap: 1, }} > - {sseEvents.length === 0 ? ( + {displayEvents.length === 0 ? ( Waiting for events... ) : ( - sseEvents.map((evt, i) => ( + displayEvents.map((evt, i) => ( {evt.step.replace(/_/g, " ")} - {evt.message && ( + {evt.message && formatProgressMessage(evt.message) && ( - {evt.message} + {formatProgressMessage(evt.message)} )} @@ -400,32 +518,22 @@ export default function FetchRequestDetail() { - {(hasAmbiguities || req.status === "paused") && ( + {hasAmbiguities && ( Ambiguity Resolution - {ambiguitiesLoading ? ( - - - Loading ambiguities... - - ) : allResolved ? ( + {allResolved ? ( All ambiguities resolved — pipeline will resume on next poll cycle - ) : !hasAmbiguities ? ( - - Pipeline paused — no ambiguities found - ) : ( Pipeline paused — resolve ambiguities to continue )} - {hasAmbiguities && ( {ambiguities.map((ambiguity: any) => { const isResolved = ambiguity.status === "resolved"; @@ -497,7 +605,6 @@ export default function FetchRequestDetail() { ); })} - )} )} diff --git a/src/features/fetch-requests/fetch-requests.models.ts b/src/features/fetch-requests/fetch-requests.models.ts index 9159ca7..bac7c28 100644 --- a/src/features/fetch-requests/fetch-requests.models.ts +++ b/src/features/fetch-requests/fetch-requests.models.ts @@ -13,6 +13,7 @@ export interface FileSource { raw_lines?: string[]; txn_blocks?: Record; txn_dicts?: Record[]; + txn_dicts_count?: number; } export interface EmailSource { @@ -20,6 +21,7 @@ export interface EmailSource { from_email?: string; subject?: string; raw_terms?: string[]; + txn_dicts_count?: number; } export interface FetchRequestCreate { @@ -80,10 +82,25 @@ export interface ResolveAmbiguityPayload { }; } +export type SSEEventStep = + | "load_content" | "raw_lines" | "txn_blocks" | "txn_dicts" + | "resume_extract" | "extract" | "paused" | "complete" | "enrich"; + +export type SSEEventStatus = + | "started" | "completed" | "skipped" | "paused" | "progress"; + +export interface ProgressMessage { + lines?: number; + blocks?: number; + count?: number; + unit?: string; + raw_ocr_line?: string; +} + export interface SSEEvent { - step: string; - status: "started" | "completed" | "skipped" | "paused"; - message: string; + step: SSEEventStep; + status: SSEEventStatus; + message: ProgressMessage; } export interface FetchRequestFilters { diff --git a/src/features/fetch-requests/index.ts b/src/features/fetch-requests/index.ts index bde2e6c..094ee8c 100644 --- a/src/features/fetch-requests/index.ts +++ b/src/features/fetch-requests/index.ts @@ -11,6 +11,9 @@ export type { AmbiguityCandidate, ResolveAmbiguityPayload, SSEEvent, + SSEEventStep, + SSEEventStatus, + ProgressMessage, } from "./fetch-requests.models"; export { RETRY_MAX } from "./fetch-requests.models"; export { -- 2.49.1 From be7c2817b7721cea79d3e67ede354f4ee8ff23af Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 30 May 2026 03:29:25 +0530 Subject: [PATCH 09/16] txn_dict --- src/FetchRequestDetail.tsx | 33 +++++++++++-------- .../fetch-requests/fetch-requests.models.ts | 5 ++- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 70c771d..8f370b1 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -81,19 +81,16 @@ function computeProgressPercent( return status === "processing" || status === "paused" ? 10 : 0; } -const stepLabels = ["Load Content", "Extract", "Validate", "Enrich", "Save"]; +const stepLabels = ["Load Content", "Extract", "Raw Expense", "Enrich", "Save"]; -function statusToActiveStep(status: FetchRequestStatus): number { - switch (status) { - case "pending": return -1; - case "processing": return 0; - case "paused": return 2; - case "raw_expenses_done": return 2; - case "enriched_done": return 3; - case "completed": return stepLabels.length; - case "failed": return 0; - default: return -1; - } +function computeActiveStep(status: FetchRequestStatus, seenSteps: Set): number { + if (status === "completed") return stepLabels.length; + if (seenSteps.has("complete")) return 4; + if (seenSteps.has("enrich") || status === "enriched_done") return 3; + if (seenSteps.has("txn_dicts") || status === "raw_expenses_done") return 2; + if (seenSteps.has("txn_blocks")) return 1; + if (seenSteps.has("load_content") || status === "processing" || status === "paused") return 0; + return -1; } function formatProgressMessage(msg: ProgressMessage): string { @@ -226,8 +223,15 @@ export default function FetchRequestDetail() { break; } } + + const terminalStatuses = new Set(["completed", "skipped", "paused"]); return sseEvents.filter((e, i) => { if (e.step === "txn_dicts" && e.status === "progress") return i === lastProgressIdx; + if (e.status === "started") { + return !sseEvents.slice(i + 1).some( + (later) => later.step === e.step && terminalStatuses.has(later.status), + ); + } return true; }); }, [sseEvents]); @@ -255,7 +259,8 @@ export default function FetchRequestDetail() { const displayParsedCount = React.useMemo(() => { if (liveParsedCount > 0) return liveParsedCount; const source = (fetchRequest as any)?.source; - if (source?.txn_dicts_count) return source.txn_dicts_count; + 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; @@ -298,7 +303,7 @@ export default function FetchRequestDetail() { } const req = fetchRequest as any; - const activeStep = statusToActiveStep(req.status); + 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") ?? []; diff --git a/src/features/fetch-requests/fetch-requests.models.ts b/src/features/fetch-requests/fetch-requests.models.ts index bac7c28..110f13a 100644 --- a/src/features/fetch-requests/fetch-requests.models.ts +++ b/src/features/fetch-requests/fetch-requests.models.ts @@ -13,6 +13,7 @@ export interface FileSource { raw_lines?: string[]; txn_blocks?: Record; txn_dicts?: Record[]; + txn_dict_count?: number; txn_dicts_count?: number; } @@ -21,6 +22,7 @@ export interface EmailSource { from_email?: string; subject?: string; raw_terms?: string[]; + txn_dict_count?: number; txn_dicts_count?: number; } @@ -84,7 +86,8 @@ export interface ResolveAmbiguityPayload { export type SSEEventStep = | "load_content" | "raw_lines" | "txn_blocks" | "txn_dicts" - | "resume_extract" | "extract" | "paused" | "complete" | "enrich"; + | "resume_extract" | "extract" | "paused" | "complete" | "enrich" + | "save_expenses"; export type SSEEventStatus = | "started" | "completed" | "skipped" | "paused" | "progress"; -- 2.49.1 From 9e206fb92b98110170d6c06e49d2445df53b1a82 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 30 May 2026 03:43:15 +0530 Subject: [PATCH 10/16] enrich and save sse --- src/FetchRequestDetail.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 8f370b1..13900a1 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -66,11 +66,11 @@ function computeProgressPercent( if (status === "pending") return 0; if (status === "completed") return 100; - if (seenSteps.has("complete")) return 90; + if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 95; if (seenSteps.has("enrich")) return 85; if (seenSteps.has("txn_dicts/completed")) return 80; - if (seenSteps.has("txn_dicts/started") && liveCount > 0) { + if (seenSteps.has("txn_dicts") && liveCount > 0) { return Math.min(80, 35 + Math.min(liveCount, 300) / 300 * 45); } @@ -85,7 +85,7 @@ const stepLabels = ["Load Content", "Extract", "Raw Expense", "Enrich", "Save"]; function computeActiveStep(status: FetchRequestStatus, seenSteps: Set): number { if (status === "completed") return stepLabels.length; - if (seenSteps.has("complete")) return 4; + if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 4; if (seenSteps.has("enrich") || status === "enriched_done") return 3; if (seenSteps.has("txn_dicts") || status === "raw_expenses_done") return 2; if (seenSteps.has("txn_blocks")) return 1; @@ -155,9 +155,12 @@ export default function FetchRequestDetail() { const dictCount = stepStats.txn_dicts ?? liveParsedCount ?? 0; if (dictCount) msgs[2] = `${dictCount} dicts`; - if (["enriched_done", "completed"].includes((fetchRequest as any)?.status)) + if (stepStats.enrich_count) msgs[3] = `${stepStats.enrich_count} enriched`; + else if (["enriched_done", "completed"].includes((fetchRequest as any)?.status)) msgs[3] = "done"; - if ((fetchRequest as any)?.status === "completed") + + if (stepStats.save_count) msgs[4] = `${stepStats.save_count} saved`; + else if ((fetchRequest as any)?.status === "completed") msgs[4] = (fetchRequest as any)?.completed_at ? new Date((fetchRequest as any).completed_at).toLocaleString() : "done"; @@ -181,11 +184,13 @@ export default function FetchRequestDetail() { setLiveParsedCount(parsed.message.count); } - if (parsed.status === "completed") { + 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" && parsed.message.count !== undefined) stats.txn_dicts = parsed.message.count; + 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 })); } -- 2.49.1 From a1a406756f37ae0e7c5319115807c236c74261bc Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 30 May 2026 03:59:19 +0530 Subject: [PATCH 11/16] enrich and save sse --- src/FetchRequestDetail.tsx | 47 ++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 13900a1..b8bbcd0 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -71,25 +71,33 @@ function computeProgressPercent( if (seenSteps.has("txn_dicts/completed")) return 80; if (seenSteps.has("txn_dicts") && liveCount > 0) { - return Math.min(80, 35 + Math.min(liveCount, 300) / 300 * 45); + return Math.min(80, 20 + Math.min(liveCount, 300) / 300 * 60); } - if (seenSteps.has("txn_blocks")) return 35; - if (seenSteps.has("raw_lines")) return 25; - if (seenSteps.has("load_content")) return 15; + if (seenSteps.has("txn_blocks") || seenSteps.has("raw_lines")) return 20; - return status === "processing" || status === "paused" ? 10 : 0; + return status === "processing" || status === "paused" ? 5 : 0; } -const stepLabels = ["Load Content", "Extract", "Raw Expense", "Enrich", "Save"]; +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") || seenSteps.has("complete")) return 4; - if (seenSteps.has("enrich") || status === "enriched_done") return 3; - if (seenSteps.has("txn_dicts") || status === "raw_expenses_done") return 2; - if (seenSteps.has("txn_blocks")) return 1; - if (seenSteps.has("load_content") || status === "processing" || status === "paused") return 0; + + 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; } @@ -147,21 +155,20 @@ export default function FetchRequestDetail() { const source = (fetchRequest as any)?.source; const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0); - if (rawLineCount) msgs[0] = `${rawLineCount} raw lines`; - const blockCount = stepStats.txn_blocks ?? 0; - if (blockCount) msgs[1] = `${blockCount} blocks`; + if (rawLineCount || blockCount) msgs[0] = blockCount ? `${blockCount} blocks` : `${rawLineCount} raw lines`; - const dictCount = stepStats.txn_dicts ?? liveParsedCount ?? 0; - if (dictCount) msgs[2] = `${dictCount} dicts`; + const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0; + const dictCount = stepStats.txn_dicts ?? liveParsedCount ?? sourceDictCount; + if (dictCount) msgs[1] = `${dictCount} dicts`; - if (stepStats.enrich_count) msgs[3] = `${stepStats.enrich_count} enriched`; + if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count} enriched`; else if (["enriched_done", "completed"].includes((fetchRequest as any)?.status)) - msgs[3] = "done"; + msgs[2] = "done"; - if (stepStats.save_count) msgs[4] = `${stepStats.save_count} saved`; + if (stepStats.save_count) msgs[3] = `${stepStats.save_count} saved`; else if ((fetchRequest as any)?.status === "completed") - msgs[4] = (fetchRequest as any)?.completed_at + msgs[3] = (fetchRequest as any)?.completed_at ? new Date((fetchRequest as any).completed_at).toLocaleString() : "done"; return msgs; -- 2.49.1 From 46281908582ed429ed237c4b0a0264fbbc779184 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 30 May 2026 04:13:46 +0530 Subject: [PATCH 12/16] enrich and save sse --- src/FetchRequestDetail.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index b8bbcd0..2dbd83d 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -145,7 +145,7 @@ export default function FetchRequestDetail() { const [sseEvents, setSseEvents] = React.useState([]); const [sseConnected, setSseConnected] = React.useState(false); - const [liveParsedCount, setLiveParsedCount] = React.useState(0); + const [liveParsedCount, setLiveParsedCount] = React.useState(undefined); const [stepStats, setStepStats] = React.useState>({}); const sseRef = React.useRef(null); const feedRef = React.useRef(null); @@ -262,14 +262,14 @@ export default function FetchRequestDetail() { const progressPercent = React.useMemo( () => computeProgressPercent( (fetchRequest as any)?.status as FetchRequestStatus ?? "pending", - liveParsedCount, + liveParsedCount ?? 0, seenSteps, ), [fetchRequest, liveParsedCount, seenSteps], ); const displayParsedCount = React.useMemo(() => { - if (liveParsedCount > 0) return liveParsedCount; + 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; -- 2.49.1 From 0133872586be44043a88d702d4e179b220aef0af Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 30 May 2026 04:38:51 +0530 Subject: [PATCH 13/16] enrich and save progress with percentage count --- src/FetchRequestDetail.tsx | 56 ++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 2dbd83d..0181f54 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -62,21 +62,43 @@ function computeProgressPercent( status: FetchRequestStatus, liveCount: number, seenSteps: Set, + stepStats: Record, ): number { if (status === "pending") return 0; if (status === "completed") return 100; - if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 95; - if (seenSteps.has("enrich")) return 85; - if (seenSteps.has("txn_dicts/completed")) return 80; + const W_EXTRACT = 10; + const W_RAW = 20; + const W_ENRICH = 50; + const W_SAVE = 20; - if (seenSteps.has("txn_dicts") && liveCount > 0) { - return Math.min(80, 20 + Math.min(liveCount, 300) / 300 * 60); + let pct = 0; + + if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += W_EXTRACT; + + const blocks = stepStats.txn_blocks ?? 0; + if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") { + pct += W_RAW; + } else if (blocks > 0 && liveCount > 0) { + pct += Math.min(1, liveCount / blocks) * W_RAW; } - if (seenSteps.has("txn_blocks") || seenSteps.has("raw_lines")) return 20; + const totalDicts = stepStats.txn_dicts ?? 0; + const enrichCount = stepStats.enrich_count ?? 0; + if (seenSteps.has("enrich/completed") || (seenSteps.has("enrich") && totalDicts > 0 && enrichCount >= totalDicts)) { + pct += W_ENRICH; + } else if (totalDicts > 0 && enrichCount > 0) { + pct += Math.min(1, enrichCount / totalDicts) * W_ENRICH; + } - return status === "processing" || status === "paused" ? 5 : 0; + const saveCount = stepStats.save_count ?? 0; + if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) { + pct += W_SAVE; + } else if (totalDicts > 0 && saveCount > 0) { + pct += Math.min(1, saveCount / totalDicts) * W_SAVE; + } + + return Math.round(Math.min(100, pct)); } const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"]; @@ -187,8 +209,10 @@ export default function FetchRequestDetail() { const parsed: SSEEvent = JSON.parse(event.data); setSseEvents((prev) => [...prev, parsed]); - if (parsed.step === "txn_dicts" && parsed.status === "progress" && parsed.message.count !== undefined) { - setLiveParsedCount(parsed.message.count); + 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) { @@ -228,17 +252,18 @@ export default function FetchRequestDetail() { }, [sseEvents]); const displayEvents = React.useMemo(() => { - let lastProgressIdx = -1; + const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]); + const lastProgressIdx: Record = {}; for (let i = sseEvents.length - 1; i >= 0; i--) { - if (sseEvents[i].step === "txn_dicts" && sseEvents[i].status === "progress") { - lastProgressIdx = i; - break; + 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"]); return sseEvents.filter((e, i) => { - if (e.step === "txn_dicts" && e.status === "progress") return i === lastProgressIdx; + 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), @@ -264,8 +289,9 @@ export default function FetchRequestDetail() { (fetchRequest as any)?.status as FetchRequestStatus ?? "pending", liveParsedCount ?? 0, seenSteps, + stepStats, ), - [fetchRequest, liveParsedCount, seenSteps], + [fetchRequest, liveParsedCount, seenSteps, stepStats], ); const displayParsedCount = React.useMemo(() => { -- 2.49.1 From 8c99251c14a126b0f31117bf7113f837338e8f9d Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 30 May 2026 05:32:21 +0530 Subject: [PATCH 14/16] enrich and save progress with percentage count --- src/FetchRequestDetail.tsx | 93 +++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 0181f54..dac90e7 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -63,39 +63,24 @@ function computeProgressPercent( liveCount: number, seenSteps: Set, stepStats: Record, + txnBlockCount: number, + txnDictCount: number, ): number { if (status === "pending") return 0; if (status === "completed") return 100; - const W_EXTRACT = 10; - const W_RAW = 20; - const W_ENRICH = 50; - const W_SAVE = 20; - let pct = 0; - if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += W_EXTRACT; + if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10; - const blocks = stepStats.txn_blocks ?? 0; - if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") { - pct += W_RAW; - } else if (blocks > 0 && liveCount > 0) { - pct += Math.min(1, liveCount / blocks) * W_RAW; + if (txnBlockCount > 0) { + const current = Math.max(liveCount, stepStats.txn_dicts ?? 0); + pct += Math.min(1, current / txnBlockCount) * 20; } - const totalDicts = stepStats.txn_dicts ?? 0; - const enrichCount = stepStats.enrich_count ?? 0; - if (seenSteps.has("enrich/completed") || (seenSteps.has("enrich") && totalDicts > 0 && enrichCount >= totalDicts)) { - pct += W_ENRICH; - } else if (totalDicts > 0 && enrichCount > 0) { - pct += Math.min(1, enrichCount / totalDicts) * W_ENRICH; - } - - const saveCount = stepStats.save_count ?? 0; - if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) { - pct += W_SAVE; - } else if (totalDicts > 0 && saveCount > 0) { - pct += Math.min(1, saveCount / totalDicts) * W_SAVE; + 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)); @@ -172,29 +157,37 @@ export default function FetchRequestDetail() { 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); - const blockCount = stepStats.txn_blocks ?? 0; - if (rawLineCount || blockCount) msgs[0] = blockCount ? `${blockCount} blocks` : `${rawLineCount} raw lines`; + if (rawLineCount) msgs[0] = `${rawLineCount}`; const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0; - const dictCount = stepStats.txn_dicts ?? liveParsedCount ?? sourceDictCount; - if (dictCount) msgs[1] = `${dictCount} dicts`; + 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}`; - if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count} enriched`; - else if (["enriched_done", "completed"].includes((fetchRequest as any)?.status)) - msgs[2] = "done"; + 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) msgs[3] = `${stepStats.save_count} saved`; - else if ((fetchRequest as any)?.status === "completed") - msgs[3] = (fetchRequest as any)?.completed_at - ? new Date((fetchRequest as any).completed_at).toLocaleString() : "done"; + 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]); + }, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]); React.useEffect(() => { if (!id || !config?.baseUrl) return; @@ -284,16 +277,6 @@ export default function FetchRequestDetail() { return steps; }, [sseEvents]); - const progressPercent = React.useMemo( - () => computeProgressPercent( - (fetchRequest as any)?.status as FetchRequestStatus ?? "pending", - liveParsedCount ?? 0, - seenSteps, - stepStats, - ), - [fetchRequest, liveParsedCount, seenSteps, stepStats], - ); - const displayParsedCount = React.useMemo(() => { if (liveParsedCount && liveParsedCount > 0) return liveParsedCount; const source = (fetchRequest as any)?.source; @@ -304,6 +287,24 @@ export default function FetchRequestDetail() { 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 { -- 2.49.1 From cbb44539a510c07e5e97db9b958f447aaf3eb301 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 30 May 2026 05:51:34 +0530 Subject: [PATCH 15/16] failure handling --- src/FetchRequestDetail.tsx | 21 ++++++++++++++++++- .../fetch-requests/fetch-requests.models.ts | 5 +++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index dac90e7..560013b 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -15,6 +15,7 @@ import { StepIcon, LinearProgress, IconButton, + Snackbar, } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ReplayIcon from "@mui/icons-material/Replay"; @@ -114,6 +115,7 @@ function formatProgressMessage(msg: ProgressMessage): string { 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 ""; } @@ -121,6 +123,7 @@ 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 ( @@ -154,6 +157,7 @@ export default function FetchRequestDetail() { 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); @@ -224,6 +228,10 @@ export default function FetchRequestDetail() { refetchRequest(); refetchAmbiguities(); } + if (parsed.status === "failed") { + setFailNotif(parsed.message.error || "Fetch request failed"); + refetchRequest(); + } if (parsed.status === "completed" || parsed.step === "resume_extract") { refetchRequest(); } @@ -254,7 +262,7 @@ export default function FetchRequestDetail() { } } - const terminalStatuses = new Set(["completed", "skipped", "paused"]); + 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") { @@ -271,6 +279,7 @@ export default function FetchRequestDetail() { 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`); } @@ -651,6 +660,16 @@ export default function FetchRequestDetail() { )} + setFailNotif(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + setFailNotif(null)} sx={{ borderRadius: 2 }}> + {failNotif} + + ); } diff --git a/src/features/fetch-requests/fetch-requests.models.ts b/src/features/fetch-requests/fetch-requests.models.ts index 110f13a..1265b4a 100644 --- a/src/features/fetch-requests/fetch-requests.models.ts +++ b/src/features/fetch-requests/fetch-requests.models.ts @@ -87,10 +87,10 @@ export interface ResolveAmbiguityPayload { export type SSEEventStep = | "load_content" | "raw_lines" | "txn_blocks" | "txn_dicts" | "resume_extract" | "extract" | "paused" | "complete" | "enrich" - | "save_expenses"; + | "save_expenses" | "pipeline"; export type SSEEventStatus = - | "started" | "completed" | "skipped" | "paused" | "progress"; + | "started" | "completed" | "skipped" | "paused" | "progress" | "failed"; export interface ProgressMessage { lines?: number; @@ -98,6 +98,7 @@ export interface ProgressMessage { count?: number; unit?: string; raw_ocr_line?: string; + error?: string; } export interface SSEEvent { -- 2.49.1 From fce68c5c5de187648de87a31820c8c91cbfb9ea8 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 30 May 2026 15:06:06 +0530 Subject: [PATCH 16/16] handling 422 status code --- src/FetchRequestDetail.tsx | 6 +++--- src/FetchRequests.tsx | 4 ++-- .../fetch-requests/fetch-requests.models.ts | 17 +++++++++++++++++ src/features/fetch-requests/index.ts | 2 +- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/FetchRequestDetail.tsx b/src/FetchRequestDetail.tsx index 560013b..b55ad77 100644 --- a/src/FetchRequestDetail.tsx +++ b/src/FetchRequestDetail.tsx @@ -36,7 +36,7 @@ import type { SSEEvent, ProgressMessage, } from "./features/fetch-requests"; -import { RETRY_MAX } from "./features/fetch-requests"; +import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; import { useConfig } from "../react-openapi"; const statusColors: Record = { @@ -318,8 +318,8 @@ export default function FetchRequestDetail() { if (!id) return; try { await updateMutation.mutateAsync({ id, data: { status: "pending" } }); - } catch { - // handled by react query + } catch (err: any) { + setFailNotif(formatApiError(err)); } }; diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx index d63d204..d1f5c8c 100644 --- a/src/FetchRequests.tsx +++ b/src/FetchRequests.tsx @@ -55,7 +55,7 @@ import type { FileSource, EmailSource, } 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 { useResourceByName, useConfig } from "../react-openapi"; @@ -180,7 +180,7 @@ export default function FetchRequests() { if (err?.response?.status === 409) { setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" }); } 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" }); } } }; diff --git a/src/features/fetch-requests/fetch-requests.models.ts b/src/features/fetch-requests/fetch-requests.models.ts index 1265b4a..ebe0399 100644 --- a/src/features/fetch-requests/fetch-requests.models.ts +++ b/src/features/fetch-requests/fetch-requests.models.ts @@ -113,4 +113,21 @@ export interface FetchRequestFilters { 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 094ee8c..cd21072 100644 --- a/src/features/fetch-requests/index.ts +++ b/src/features/fetch-requests/index.ts @@ -15,7 +15,7 @@ export type { SSEEventStatus, ProgressMessage, } from "./fetch-requests.models"; -export { RETRY_MAX } from "./fetch-requests.models"; +export { RETRY_MAX, formatApiError } from "./fetch-requests.models"; export { useFetchRequestsList, useFetchRequest, -- 2.49.1