fetch request fixes

This commit is contained in:
2026-07-11 17:11:27 +05:30
parent afddb3cfbc
commit 99cc5afb7c
3 changed files with 211 additions and 407 deletions

View File

@@ -12,7 +12,6 @@ import {
Stepper, Stepper,
Step, Step,
StepLabel, StepLabel,
StepIcon,
LinearProgress, LinearProgress,
IconButton, IconButton,
Snackbar, Snackbar,
@@ -35,7 +34,7 @@ import type {
ProgressMessage, ProgressMessage,
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useAppContext, useResource } from "../react-openapi"; import { useAppContext, useResource, useItemSse } from "../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = { const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
@@ -58,6 +57,8 @@ const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
failed: <ErrorIcon sx={{ fontSize: 16 }} />, failed: <ErrorIcon sx={{ fontSize: 16 }} />,
}; };
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeProgressPercent( function computeProgressPercent(
status: FetchRequestStatus, status: FetchRequestStatus,
liveCount: number, liveCount: number,
@@ -86,8 +87,6 @@ function computeProgressPercent(
return Math.round(Math.min(100, pct)); return Math.round(Math.min(100, pct));
} }
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number { function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number {
if (status === "completed") return stepLabels.length; if (status === "completed") return stepLabels.length;
@@ -125,27 +124,13 @@ function sseIcon(status: SSEEvent["status"]) {
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />; case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />; case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />; case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
case "progress": return ( case "progress": return <FiberManualRecordIcon sx={{ fontSize: 14, color: "info.main" }} />;
<FiberManualRecordIcon
sx={{ fontSize: 14, color: "info.main" }}
/>
);
} }
} }
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() { export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { config } = useAppContext();
const { get, update } = useResource("fetch-requests"); const { get, update } = useResource("fetch-requests");
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({ const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
@@ -159,59 +144,14 @@ export default function FetchRequestDetail() {
const resolveMutation = useResolveAmbiguity(); const resolveMutation = useResolveAmbiguity();
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!); const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
const [sseEvents, setSseEvents] = React.useState<SSEEvent[]>([]);
const [sseConnected, setSseConnected] = React.useState(false);
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [stepStats, setStepStats] = React.useState<Record<string, number>>({}); const [stepStats, setStepStats] = React.useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [failNotif, setFailNotif] = React.useState<string | null>(null); const [failNotif, setFailNotif] = React.useState<string | null>(null);
const sseRef = React.useRef<EventSource | null>(null);
const feedRef = React.useRef<HTMLDivElement>(null); const feedRef = React.useRef<HTMLDivElement>(null);
const txnBlockCount = React.useMemo(() => { const sseUrl = id ? `/fetch-requests/${id}/events` : null;
const blocks = (fetchRequest as any)?.source?.txn_blocks; const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
if (!blocks) return 0; onEvent: (parsed: SSEEvent) => {
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<number, string> = {};
const source = (fetchRequest as any)?.source;
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
if (rawLineCount) msgs[0] = `${rawLineCount}`;
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
const dictCurrent = Math.max(dictLive, sourceDictCount);
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
return msgs;
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
React.useEffect(() => {
if (!id || !config?.baseApiUrl) return;
const url = `${config.baseApiUrl}/fetch-requests/${id}/events`;
const es = new EventSource(url);
sseRef.current = es;
es.onopen = () => setSseConnected(true);
es.onerror = () => setSseConnected(false);
es.onmessage = (event) => {
try {
const parsed: SSEEvent = JSON.parse(event.data);
setSseEvents((prev) => [...prev, parsed]);
if (parsed.status === "progress" && parsed.message.count !== undefined) { if (parsed.status === "progress" && parsed.message.count !== undefined) {
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count); if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! })); if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
@@ -241,16 +181,8 @@ export default function FetchRequestDetail() {
if (parsed.status === "completed" || parsed.step === "resume_extract") { if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest(); refetchRequest();
} }
} catch { },
// ignore malformed events });
}
};
return () => {
es.close();
sseRef.current = null;
};
}, [id, config?.baseApiUrl]);
React.useEffect(() => { React.useEffect(() => {
if (feedRef.current) { if (feedRef.current) {
@@ -258,27 +190,14 @@ export default function FetchRequestDetail() {
} }
}, [sseEvents]); }, [sseEvents]);
const displayEvents = React.useMemo(() => { const txnBlockCount = React.useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]); const blocks = (fetchRequest as any)?.source?.txn_blocks;
const lastProgressIdx: Record<string, number> = {}; if (!blocks) return 0;
for (let i = sseEvents.length - 1; i >= 0; i--) { return Object.values(blocks).reduce(
const e = sseEvents[i]; (sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) { 0,
lastProgressIdx[e.step] = i;
}
}
const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]);
return sseEvents.filter((e, i) => {
if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step];
if (e.status === "started") {
return !sseEvents.slice(i + 1).some(
(later) => later.step === e.step && terminalStatuses.has(later.status),
); );
} }, [fetchRequest]);
return true;
});
}, [sseEvents]);
const seenSteps = React.useMemo(() => { const seenSteps = React.useMemo(() => {
const steps = new Set<string>(); const steps = new Set<string>();
@@ -308,6 +227,29 @@ export default function FetchRequestDetail() {
return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0; return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
}, [fetchRequest, stepStats]); }, [fetchRequest, stepStats]);
const stepMessages = React.useMemo(() => {
const msgs: Record<number, string> = {};
const source = (fetchRequest as any)?.source;
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
if (rawLineCount) msgs[0] = `${rawLineCount}`;
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
const dictCurrent = Math.max(dictLive, sourceDictCount);
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
return msgs;
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
const progressPercent = React.useMemo( const progressPercent = React.useMemo(
() => computeProgressPercent( () => computeProgressPercent(
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending", (fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
@@ -320,6 +262,28 @@ export default function FetchRequestDetail() {
[fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount], [fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount],
); );
const displayEvents = React.useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
const lastProgressIdx: Record<string, number> = {};
for (let i = sseEvents.length - 1; i >= 0; i--) {
const e = sseEvents[i];
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) {
lastProgressIdx[e.step] = i;
}
}
const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]);
return sseEvents.filter((e, i) => {
if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step];
if (e.status === "started") {
return !sseEvents.slice(i + 1).some(
(later) => later.step === e.step && terminalStatuses.has(later.status),
);
}
return true;
});
}, [sseEvents]);
const handleRetry = async () => { const handleRetry = async () => {
if (!id) return; if (!id) return;
try { try {
@@ -360,11 +324,8 @@ export default function FetchRequestDetail() {
const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps); const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps);
const retryCount = req.retry_count ?? 0; const retryCount = req.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX; 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 hasAmbiguities = ambiguities && ambiguities.length > 0;
const allResolved = hasAmbiguities && pendingAmbiguities.length === 0; const allResolved = hasAmbiguities && ambiguities.every((a: any) => a.status === "resolved");
const ambiguitiesLoading = !ambiguities;
return ( return (
<Container sx={{ mt: 4, mb: 4 }}> <Container sx={{ mt: 4, mb: 4 }}>
@@ -372,6 +333,7 @@ export default function FetchRequestDetail() {
Back to Fetch Requests Back to Fetch Requests
</Button> </Button>
{/* Header Card */}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined"> <Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip <Chip
@@ -392,7 +354,7 @@ export default function FetchRequestDetail() {
<Box> <Box>
<Typography variant="caption" color="text.secondary">Date Range</Typography> <Typography variant="caption" color="text.secondary">Date Range</Typography>
<Typography variant="body2"> <Typography variant="body2">
{(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() : "?"} {req.start_date ? new Date(req.start_date).toLocaleDateString() : "?"} {req.end_date ? new Date(req.end_date).toLocaleDateString() : "?"}
</Typography> </Typography>
</Box> </Box>
<Box> <Box>
@@ -407,11 +369,10 @@ export default function FetchRequestDetail() {
)} )}
</Box> </Box>
{/* Progress Bar */}
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">Overall Progress</Typography>
Overall Progress
</Typography>
{["processing", "paused"].includes(req.status) && displayParsedCount > 0 && ( {["processing", "paused"].includes(req.status) && displayParsedCount > 0 && (
<Typography variant="caption" fontWeight={600} color="info.main"> <Typography variant="caption" fontWeight={600} color="info.main">
Validated: {displayParsedCount} transactions Validated: {displayParsedCount} transactions
@@ -429,6 +390,7 @@ export default function FetchRequestDetail() {
</Typography> </Typography>
</Box> </Box>
{/* Retry Counter */}
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}> <Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -455,18 +417,19 @@ export default function FetchRequestDetail() {
</Box> </Box>
</Paper> </Paper>
{/* Error Alerts */}
{req.status === "failed" && req.error_message && ( {req.status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}> <Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{req.error_message} {req.error_message}
</Alert> </Alert>
)} )}
{isRetryExhausted && req.status === "failed" && ( {isRetryExhausted && req.status === "failed" && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}> <Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Max retries reached no further retry attempts will be made. Max retries reached no further retry attempts will be made.
</Alert> </Alert>
)} )}
{/* Pipeline Stepper */}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined"> <Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom> <Typography variant="subtitle1" fontWeight={600} gutterBottom>
Pipeline Progress Pipeline Progress
@@ -491,17 +454,15 @@ export default function FetchRequestDetail() {
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>; icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
} }
const stepMsg = stepMessages[index];
return ( return (
<Step key={label}> <Step key={label}>
<StepLabel <StepLabel
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>} StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
> >
<Typography variant="body2" fontWeight={600}>{label}</Typography> <Typography variant="body2" fontWeight={600}>{label}</Typography>
{stepMsg && ( {stepMessages[index] && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}> <Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
{stepMsg} {stepMessages[index]}
</Typography> </Typography>
)} )}
</StepLabel> </StepLabel>
@@ -511,6 +472,7 @@ export default function FetchRequestDetail() {
</Stepper> </Stepper>
</Paper> </Paper>
{/* SSE Event Feed */}
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined"> <Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}> <Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
@@ -568,15 +530,13 @@ export default function FetchRequestDetail() {
</Typography> </Typography>
)} )}
</Box> </Box>
<Typography variant="caption" color="text.disabled">
{new Date().toLocaleTimeString()}
</Typography>
</Box> </Box>
)) ))
)} )}
</Box> </Box>
</Paper> </Paper>
{/* Ambiguity Resolution */}
{hasAmbiguities && ( {hasAmbiguities && (
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined"> <Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom> <Typography variant="subtitle1" fontWeight={600} gutterBottom>
@@ -666,6 +626,7 @@ export default function FetchRequestDetail() {
</Box> </Box>
</Paper> </Paper>
)} )}
<Snackbar <Snackbar
open={!!failNotif} open={!!failNotif}
autoHideDuration={6000} autoHideDuration={6000}

View File

@@ -25,6 +25,12 @@ import {
FormControl, FormControl,
OutlinedInput, OutlinedInput,
Autocomplete, Autocomplete,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material"; } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
@@ -36,9 +42,7 @@ import ErrorIcon from "@mui/icons-material/Error";
import ScheduleIcon from "@mui/icons-material/Schedule"; import ScheduleIcon from "@mui/icons-material/Schedule";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty"; import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { import { useUploadFile } from "./features/fetch-requests";
useUploadFile,
} from "./features/fetch-requests";
import type { import type {
FetchRequest, FetchRequest,
FetchRequestStatus, FetchRequestStatus,
@@ -47,7 +51,7 @@ import type {
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useResource, FormFieldRenderer } from "../react-openapi"; import { useResource, FormFieldRenderer, applyDisplayFormat } from "../react-openapi";
import type { FieldConfig } from "../react-openapi"; import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
@@ -81,18 +85,6 @@ const STATUS_OPTIONS: FetchRequestStatus[] = [
"failed", "failed",
]; ];
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
}
function formatDateRange(start?: string, end?: string) {
if (!start && !end) return "\u2014";
const s = start ? new Date(start).toLocaleDateString() : "?";
const e = end ? new Date(end).toLocaleDateString() : "?";
return `${s} \u2192 ${e}`;
}
function shortId(fp: string) { function shortId(fp: string) {
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp; return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
} }
@@ -118,8 +110,7 @@ export default function FetchRequests() {
const [accountFilter, setAccountFilter] = React.useState(""); const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all"); const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
const fr = useResource("fetch-requests"); const { list, create, update, remove, resource: fetchRes } = useResource("fetch-requests");
const { list, create, update, remove, resource: fetchRes } = fr;
const { data: listData, isLoading, isFetching, refetch } = useQuery({ const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }], queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }],
@@ -141,20 +132,15 @@ export default function FetchRequests() {
const fields = fetchRes?.orderedFields ?? []; const fields = fetchRes?.orderedFields ?? [];
const formatField: FieldConfig | undefined = fields.find(f => f.name === "format"); const formatField: FieldConfig | undefined = fields.find(f => f.name === "format");
const formatOptions: string[] = formatField?.enumValues ?? [];
const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date"); const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date");
const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date"); const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date");
const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username"); const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username");
const createMutation = useMutation({ const createMutation = useMutation({ mutationFn: (data: any) => create(data) });
mutationFn: (data: any) => create(data),
});
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data), mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({ mutationFn: (id: string) => remove(id) });
mutationFn: (id: string) => remove(id),
});
const uploadMutation = useUploadFile(); const uploadMutation = useUploadFile();
const requests = listData?.items ?? []; const requests = listData?.items ?? [];
@@ -199,7 +185,7 @@ export default function FetchRequests() {
navigate(`/fetch-requests/${result.id}`); navigate(`/fetch-requests/${result.id}`);
} catch (err: any) { } catch (err: any) {
if (err?.response?.status === 409) { if (err?.response?.status === 409) {
setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" }); setSnackbar({ message: "Duplicate same fingerprint already exists", severity: "error" });
} else { } else {
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" }); setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
} }
@@ -238,14 +224,13 @@ export default function FetchRequests() {
setDeleteTarget(null); setDeleteTarget(null);
}; };
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
return ( return (
<Container sx={{ mt: 4, mb: 4 }}> <Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom> <Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline Fetch Request Pipeline
</Typography> </Typography>
{/* Create Form */}
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined"> <Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom> <Typography variant="subtitle1" fontWeight={600} gutterBottom>
New Fetch Request New Fetch Request
@@ -286,40 +271,14 @@ export default function FetchRequests() {
Uploaded as: {uploadedPath} Uploaded as: {uploadedPath}
</Alert> </Alert>
)} )}
{formatField ? ( {formatField && (
<FormFieldRenderer <FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
)} )}
</> </>
) : ( ) : (
<> <>
{formatField ? ( {formatField && (
<FormFieldRenderer <FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
)} )}
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" /> <TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
<TextField label="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" /> <TextField label="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
@@ -334,58 +293,22 @@ export default function FetchRequests() {
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label="Account Name" size="small" required /> <TextField {...params} label="Account Name" size="small" required />
)} )}
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/> />
{payorUsernameField ? (
<FormFieldRenderer {payorUsernameField && (
field={payorUsernameField} <FormFieldRenderer field={payorUsernameField} value={payorUsername} onChange={setPayorUsername} />
value={payorUsername}
onChange={setPayorUsername}
/>
) : (
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
)} )}
<Box sx={{ display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2 }}>
{startDateField ? ( {startDateField && (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<FormFieldRenderer <FormFieldRenderer field={startDateField} value={startDate} onChange={setStartDate} />
field={startDateField}
value={startDate}
onChange={setStartDate}
/>
</Box> </Box>
) : (
<TextField
label="Start Date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)} )}
{endDateField ? ( {endDateField && (
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<FormFieldRenderer <FormFieldRenderer field={endDateField} value={endDate} onChange={setEndDate} />
field={endDateField}
value={endDate}
onChange={setEndDate}
/>
</Box> </Box>
) : (
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)} )}
</Box> </Box>
@@ -399,6 +322,7 @@ export default function FetchRequests() {
</Box> </Box>
</Paper> </Paper>
{/* Filters */}
<Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined"> <Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 200 }}> <FormControl size="small" sx={{ minWidth: 200 }}>
@@ -410,7 +334,7 @@ export default function FetchRequests() {
input={<OutlinedInput label="Status" />} input={<OutlinedInput label="Status" />}
renderValue={(selected) => (selected as string[]).join(", ")} renderValue={(selected) => (selected as string[]).join(", ")}
> >
{STATUS_OPTIONS.map((s: string) => ( {STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem> <MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))} ))}
</Select> </Select>
@@ -422,7 +346,7 @@ export default function FetchRequests() {
renderInput={(params) => ( renderInput={(params) => (
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} /> <TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
)} )}
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }} sx={{ minWidth: 160 }}
/> />
<ToggleButtonGroup <ToggleButtonGroup
value={sourceFilter} value={sourceFilter}
@@ -430,11 +354,9 @@ export default function FetchRequests() {
onChange={(_, val) => val && setSourceFilter(val)} onChange={(_, val) => val && setSourceFilter(val)}
size="small" size="small"
> >
{sourceTypeOptions.map((opt) => ( <ToggleButton value="all">All</ToggleButton>
<ToggleButton key={opt} value={opt}> <ToggleButton value="file">File</ToggleButton>
{opt === "all" ? "All" : opt === "file" ? "File" : "Email"} <ToggleButton value="email">Email</ToggleButton>
</ToggleButton>
))}
</ToggleButtonGroup> </ToggleButtonGroup>
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}> <IconButton onClick={() => refetch()} disabled={isFetching}>
@@ -443,6 +365,7 @@ export default function FetchRequests() {
</Box> </Box>
</Paper> </Paper>
{/* List Table */}
{isLoading ? ( {isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress /> <CircularProgress />
@@ -452,41 +375,35 @@ export default function FetchRequests() {
No fetch requests yet No fetch requests yet
</Box> </Box>
) : ( ) : (
<Paper variant="outlined" sx={{ borderRadius: 4 }}> <TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
<Box sx={{ overflowX: "auto" }}> <Table size="small">
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}> <TableHead>
<Box component="thead"> <TableRow>
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}> <TableCell>ID</TableCell>
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => ( <TableCell>Account</TableCell>
<Box <TableCell>Source</TableCell>
key={h} <TableCell>Date Range</TableCell>
component="th" <TableCell>Status</TableCell>
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }} <TableCell>Retries</TableCell>
> <TableCell>Created</TableCell>
{h} <TableCell align="right">Actions</TableCell>
</Box> </TableRow>
))} </TableHead>
</Box> <TableBody>
</Box>
<Box component="tbody">
{[...requests] {[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => ( .map((req: FetchRequest) => (
<Box <TableRow
key={req.id} key={req.id}
component="tr" hover
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/fetch-requests/${req.id}`)} onClick={() => navigate(`/fetch-requests/${req.id}`)}
sx={{
cursor: "pointer",
borderBottom: 1,
borderColor: "divider",
"&:hover": { bgcolor: "action.hover" },
"&:last-child": { borderBottom: 0 },
}}
> >
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}> <TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{shortId(req.fingerprint)} {shortId(req.fingerprint)}
</Typography>
<IconButton <IconButton
size="small" size="small"
onClick={(e) => { onClick={(e) => {
@@ -499,25 +416,22 @@ export default function FetchRequests() {
<ContentCopyIcon sx={{ fontSize: 14 }} /> <ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton> </IconButton>
</Box> </Box>
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}> <TableCell>{req.account_name}</TableCell>
{req.account_name} <TableCell>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Chip <Chip
label={"path" in req.source ? "File" : "Email"} label={"path" in req.source ? "File" : "Email"}
size="small" size="small"
variant="outlined" variant="outlined"
color={"path" in req.source ? "primary" : "secondary"} color={"path" in req.source ? "primary" : "secondary"}
/> />
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5 }}> <TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}> <Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{formatDateRange((req as any).start_date, (req as any).end_date)} {(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() : "?"}
</Typography> </Typography>
</Box> </TableCell>
<Box component="td" sx={{ px: 2, py: 1.5 }}> <TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}> <Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
<Chip <Chip
icon={statusIcons[req.status] as any} icon={statusIcons[req.status] as any}
@@ -526,69 +440,45 @@ export default function FetchRequests() {
size="small" size="small"
/> />
</Tooltip> </Tooltip>
</Box> </TableCell>
</Box> <TableCell>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{(req.retry_count ?? 0) > 0 ? (
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}> <Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{req.retry_count}/{RETRY_MAX} {(req.retry_count ?? 0) > 0 ? `${req.retry_count}/${RETRY_MAX}` : "—"}
</Typography> </Typography>
) : ( </TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}> <TableCell>
\u2014 <Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{new Date(req.created_at).toLocaleString()}
</Typography> </Typography>
)} </TableCell>
</Box> <TableCell align="right">
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
{formatDate(req.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}> <Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{req.status === "paused" && ( {req.status === "paused" && (
<Tooltip title="Resolve ambiguities"> <Tooltip title="Resolve ambiguities">
<IconButton <IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/fetch-requests/${req.id}`); }}>
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/fetch-requests/${req.id}`);
}}
>
<WarningAmberIcon fontSize="small" color="warning" /> <WarningAmberIcon fontSize="small" color="warning" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && ( {req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
<Tooltip title="Retry"> <Tooltip title="Retry">
<IconButton <IconButton size="small" onClick={(e) => { e.stopPropagation(); handleRetry(req); }}>
size="small"
onClick={(e) => {
e.stopPropagation();
handleRetry(req);
}}
>
<ReplayIcon fontSize="small" /> <ReplayIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
<Tooltip title="Delete"> <Tooltip title="Delete">
<IconButton <IconButton size="small" onClick={(e) => { e.stopPropagation(); setDeleteTarget(req); }}>
size="small"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(req);
}}
>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</Box> </Box>
</Box> </TableCell>
</Box> </TableRow>
))} ))}
</Box> </TableBody>
</Box> </Table>
</Box> </TableContainer>
</Paper>
)} )}
<Snackbar <Snackbar

View File

@@ -1,49 +1,7 @@
import { useResource, getApi } from "../../../react-openapi"; import { getApi } from "../../../react-openapi";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ResolveAmbiguityPayload } from "./fetch-requests.models"; import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
export function useFetchRequestsList(params?: {
status?: string;
account_name?: string;
source_type?: string;
}) {
const { list } = useResource("fetch-requests");
return useQuery({
queryKey: ["fetch-requests", "list", params],
queryFn: () => list(params),
});
}
export function useFetchRequest(id: string) {
const { get } = useResource("fetch-requests");
return useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id),
enabled: !!id,
});
}
export function useCreateFetchRequest() {
const { create } = useResource("fetch-requests");
return useMutation({
mutationFn: (data: any) => create(data),
});
}
export function useUpdateFetchRequest() {
const { update } = useResource("fetch-requests");
return useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
}
export function useDeleteFetchRequest() {
const { remove } = useResource("fetch-requests");
return useMutation({
mutationFn: (id: string) => remove(id),
});
}
export function useUploadFile() { export function useUploadFile() {
return useMutation({ return useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
@@ -66,9 +24,7 @@ export function useFetchRequestAmbiguities(fetchRequestId: string) {
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"], queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
queryFn: async () => { queryFn: async () => {
const api = getApi(); const api = getApi();
const res = await api.get( const res = await api.get(`/fetch-requests/${fetchRequestId}/ambiguities`);
`/fetch-requests/${fetchRequestId}/ambiguities`
);
return res.data; return res.data;
}, },
enabled: !!fetchRequestId, enabled: !!fetchRequestId,
@@ -87,10 +43,7 @@ export function useResolveAmbiguity() {
payload: ResolveAmbiguityPayload; payload: ResolveAmbiguityPayload;
}) => { }) => {
const api = getApi(); const api = getApi();
const res = await api.post( const res = await api.post(`/ambiguities/${ambiguityId}/resolve`, payload);
`/ambiguities/${ambiguityId}/resolve`,
payload
);
return res.data; return res.data;
}, },
onSuccess: (data: any) => { onSuccess: (data: any) => {