fetch request fixes
This commit is contained in:
@@ -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,14 +144,52 @@ 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 sseUrl = id ? `/fetch-requests/${id}/events` : null;
|
||||||
|
const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
|
||||||
|
onEvent: (parsed: SSEEvent) => {
|
||||||
|
if (parsed.status === "progress" && parsed.message.count !== undefined) {
|
||||||
|
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
|
||||||
|
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
|
||||||
|
if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.status === "completed" && parsed.message.count !== undefined) {
|
||||||
|
const stats: Record<string, number> = {};
|
||||||
|
if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines;
|
||||||
|
if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks;
|
||||||
|
if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count;
|
||||||
|
if (parsed.step === "enrich") stats.enrich_count = parsed.message.count;
|
||||||
|
if (parsed.step === "save_expenses") stats.save_count = parsed.message.count;
|
||||||
|
if (Object.keys(stats).length) {
|
||||||
|
setStepStats((prev) => ({ ...prev, ...stats }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.status === "paused") {
|
||||||
|
refetchRequest();
|
||||||
|
refetchAmbiguities();
|
||||||
|
}
|
||||||
|
if (parsed.status === "failed") {
|
||||||
|
setFailNotif(parsed.message.error || "Fetch request failed");
|
||||||
|
refetchRequest();
|
||||||
|
}
|
||||||
|
if (parsed.status === "completed" || parsed.step === "resume_extract") {
|
||||||
|
refetchRequest();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (feedRef.current) {
|
||||||
|
feedRef.current.scrollTop = feedRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [sseEvents]);
|
||||||
|
|
||||||
const txnBlockCount = React.useMemo(() => {
|
const txnBlockCount = React.useMemo(() => {
|
||||||
const blocks = (fetchRequest as any)?.source?.txn_blocks;
|
const blocks = (fetchRequest as any)?.source?.txn_blocks;
|
||||||
if (!blocks) return 0;
|
if (!blocks) return 0;
|
||||||
@@ -176,110 +199,6 @@ export default function FetchRequestDetail() {
|
|||||||
);
|
);
|
||||||
}, [fetchRequest]);
|
}, [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.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
|
|
||||||
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
|
|
||||||
if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.status === "completed" && parsed.message.count !== undefined) {
|
|
||||||
const stats: Record<string, number> = {};
|
|
||||||
if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines;
|
|
||||||
if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks;
|
|
||||||
if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count;
|
|
||||||
if (parsed.step === "enrich") stats.enrich_count = parsed.message.count;
|
|
||||||
if (parsed.step === "save_expenses") stats.save_count = parsed.message.count;
|
|
||||||
if (Object.keys(stats).length) {
|
|
||||||
setStepStats((prev) => ({ ...prev, ...stats }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.status === "paused") {
|
|
||||||
refetchRequest();
|
|
||||||
refetchAmbiguities();
|
|
||||||
}
|
|
||||||
if (parsed.status === "failed") {
|
|
||||||
setFailNotif(parsed.message.error || "Fetch request failed");
|
|
||||||
refetchRequest();
|
|
||||||
}
|
|
||||||
if (parsed.status === "completed" || parsed.step === "resume_extract") {
|
|
||||||
refetchRequest();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore malformed events
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
es.close();
|
|
||||||
sseRef.current = null;
|
|
||||||
};
|
|
||||||
}, [id, config?.baseApiUrl]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (feedRef.current) {
|
|
||||||
feedRef.current.scrollTop = feedRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [sseEvents]);
|
|
||||||
|
|
||||||
const displayEvents = React.useMemo(() => {
|
|
||||||
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
|
|
||||||
const lastProgressIdx: Record<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 seenSteps = React.useMemo(() => {
|
const seenSteps = React.useMemo(() => {
|
||||||
const steps = new Set<string>();
|
const steps = new Set<string>();
|
||||||
for (const evt of sseEvents) {
|
for (const evt of sseEvents) {
|
||||||
@@ -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>
|
||||||
@@ -664,18 +624,19 @@ export default function FetchRequestDetail() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
<Snackbar
|
|
||||||
open={!!failNotif}
|
<Snackbar
|
||||||
autoHideDuration={6000}
|
open={!!failNotif}
|
||||||
onClose={() => setFailNotif(null)}
|
autoHideDuration={6000}
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
onClose={() => setFailNotif(null)}
|
||||||
>
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||||
<Alert severity="error" onClose={() => setFailNotif(null)} sx={{ borderRadius: 2 }}>
|
>
|
||||||
{failNotif}
|
<Alert severity="error" onClose={() => setFailNotif(null)} sx={{ borderRadius: 2 }}>
|
||||||
</Alert>
|
{failNotif}
|
||||||
</Snackbar>
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
{[...requests]
|
||||||
<Box component="tbody">
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
{[...requests]
|
.map((req: FetchRequest) => (
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
<TableRow
|
||||||
.map((req: FetchRequest) => (
|
|
||||||
<Box
|
|
||||||
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 }}>
|
||||||
{shortId(req.fingerprint)}
|
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||||
|
{shortId(req.fingerprint)}
|
||||||
|
</Typography>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -499,96 +416,69 @@ 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}
|
label={req.status.replace(/_/g, " ")}
|
||||||
label={req.status.replace(/_/g, " ")}
|
color={statusColors[req.status]}
|
||||||
color={statusColors[req.status]}
|
size="small"
|
||||||
size="small"
|
/>
|
||||||
/>
|
</Tooltip>
|
||||||
</Tooltip>
|
</TableCell>
|
||||||
</Box>
|
<TableCell>
|
||||||
</Box>
|
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||||
<Box component="td" sx={{ px: 2, py: 1.5 }}>
|
{(req.retry_count ?? 0) > 0 ? `${req.retry_count}/${RETRY_MAX}` : "—"}
|
||||||
{(req.retry_count ?? 0) > 0 ? (
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
</TableCell>
|
||||||
{req.retry_count}/{RETRY_MAX}
|
<TableCell>
|
||||||
</Typography>
|
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
||||||
) : (
|
{new Date(req.created_at).toLocaleString()}
|
||||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
|
</Typography>
|
||||||
\u2014
|
</TableCell>
|
||||||
</Typography>
|
<TableCell align="right">
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user