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" }, ];