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, refetch: refetchRequest } = useFetchRequest(id!); const updateMutation = useUpdateFetchRequest(); 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); 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]); if (parsed.status === "paused") { refetchRequest(); refetchAmbiguities(); } } 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; 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()} )} 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"} {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 || req.status === "paused") && ( Ambiguity Resolution {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"; 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 ( ); })} )} ); })} )} )} ); }