Compare commits
5 Commits
1.1.2
...
034e0ad29a
| Author | SHA1 | Date | |
|---|---|---|---|
| 034e0ad29a | |||
| 8c8808e18b | |||
| acbfca94f2 | |||
| ecdfc2094e | |||
| c920276293 |
@@ -46,6 +46,10 @@ export const api = {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.delete(...args);
|
||||
},
|
||||
patch: (...args: Parameters<AxiosInstance["patch"]>) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.patch(...args);
|
||||
},
|
||||
};
|
||||
|
||||
export const auth = {
|
||||
|
||||
@@ -73,6 +73,23 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
|
||||
},
|
||||
});
|
||||
|
||||
// --- PATCH ---
|
||||
const usePatch = () =>
|
||||
useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||
if (!endpoint) throw new Error("Endpoint not defined");
|
||||
// @ts-ignore
|
||||
const res = await api.patch<T>(`${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<T = any>(config: ResourceConfig | undefined) {
|
||||
useMe,
|
||||
useCreate,
|
||||
useUpdate,
|
||||
usePatch,
|
||||
useUpdateMe,
|
||||
useDelete,
|
||||
getListQueryOptions,
|
||||
|
||||
502
src/FetchRequestDetail.tsx
Normal file
502
src/FetchRequestDetail.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
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<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||
pending: "default",
|
||||
processing: "info",
|
||||
paused: "warning",
|
||||
raw_expenses_done: "primary",
|
||||
enriched_done: "warning",
|
||||
completed: "success",
|
||||
failed: "error",
|
||||
};
|
||||
|
||||
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
|
||||
pending: <PlayArrowIcon sx={{ fontSize: 16 }} />,
|
||||
processing: <CircularProgress size={14} />,
|
||||
paused: <WarningAmberIcon sx={{ fontSize: 16 }} />,
|
||||
raw_expenses_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
|
||||
enriched_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
|
||||
completed: <CheckCircleIcon sx={{ fontSize: 16 }} />,
|
||||
failed: <ErrorIcon sx={{ fontSize: 16 }} />,
|
||||
};
|
||||
|
||||
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 <CircularProgress size={14} />;
|
||||
case "completed": return <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />;
|
||||
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.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() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const config = useConfig();
|
||||
|
||||
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useFetchRequest(id!);
|
||||
const updateMutation = useUpdateFetchRequest();
|
||||
const resolveMutation = useResolveAmbiguity();
|
||||
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
|
||||
|
||||
const stepMessages = React.useMemo(() => {
|
||||
const msgs: Record<number, string> = {};
|
||||
const source = (fetchRequest as any)?.source;
|
||||
|
||||
if (source?.raw_lines?.length)
|
||||
msgs[0] = `${source.raw_lines.length} raw lines`;
|
||||
|
||||
const blocks = source?.txn_blocks ?? {};
|
||||
const dicts = source?.txn_dicts ?? [];
|
||||
const blockCount = typeof blocks === "object"
|
||||
? Object.keys(blocks).length : Array.isArray(blocks) ? blocks.length : 0;
|
||||
if (blockCount || dicts.length) {
|
||||
const parts: string[] = [];
|
||||
if (blockCount) parts.push(`${blockCount} blocks`);
|
||||
if (dicts.length) parts.push(`${dicts.length} dicts`);
|
||||
msgs[1] = parts.join(" · ");
|
||||
}
|
||||
|
||||
if (["enriched_done", "completed"].includes((fetchRequest as any)?.status))
|
||||
msgs[2] = "done";
|
||||
if ((fetchRequest as any)?.status === "completed")
|
||||
msgs[3] = (fetchRequest as any)?.completed_at
|
||||
? new Date((fetchRequest as any).completed_at).toLocaleString() : "done";
|
||||
|
||||
return msgs;
|
||||
}, [fetchRequest]);
|
||||
|
||||
const [sseEvents, setSseEvents] = React.useState<SSEEvent[]>([]);
|
||||
const [sseConnected, setSseConnected] = React.useState(false);
|
||||
const sseRef = React.useRef<EventSource | null>(null);
|
||||
const feedRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!id || !config?.baseUrl) return;
|
||||
const url = `${config.baseUrl}/fetch-requests/${id}/events`;
|
||||
const es = new EventSource(url);
|
||||
sseRef.current = es;
|
||||
|
||||
es.onopen = () => setSseConnected(true);
|
||||
es.onerror = () => setSseConnected(false);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const parsed: SSEEvent = JSON.parse(event.data);
|
||||
setSseEvents((prev) => [...prev, parsed]);
|
||||
if (parsed.status === "paused") {
|
||||
refetchRequest();
|
||||
refetchAmbiguities();
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
sseRef.current = null;
|
||||
};
|
||||
}, [id, config?.baseUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (feedRef.current) {
|
||||
feedRef.current.scrollTop = feedRef.current.scrollHeight;
|
||||
}
|
||||
}, [sseEvents]);
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id, data: { status: "pending" } });
|
||||
} catch {
|
||||
// handled by react query
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = async (ambiguity: any, candidate: { amount: number; balance: number }) => {
|
||||
await resolveMutation.mutateAsync({
|
||||
ambiguityId: ambiguity.id,
|
||||
payload: { chosen: { amount: candidate.amount, balance: candidate.balance } },
|
||||
});
|
||||
refetchAmbiguities();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError || !fetchRequest) {
|
||||
return (
|
||||
<Container sx={{ mt: 4 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
|
||||
Back
|
||||
</Button>
|
||||
<Alert severity="error">Failed to load fetch request</Alert>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const req = fetchRequest as any;
|
||||
const activeStep = statusToActiveStep(req.status);
|
||||
const retryCount = req.retry_count ?? 0;
|
||||
const isRetryExhausted = retryCount >= RETRY_MAX;
|
||||
const pendingAmbiguities = ambiguities?.filter((a: any) => a.status === "pending") ?? [];
|
||||
const resolvedAmbiguities = ambiguities?.filter((a: any) => a.status === "resolved") ?? [];
|
||||
const hasAmbiguities = ambiguities && ambiguities.length > 0;
|
||||
const allResolved = hasAmbiguities && pendingAmbiguities.length === 0;
|
||||
const ambiguitiesLoading = !ambiguities;
|
||||
|
||||
return (
|
||||
<Container sx={{ mt: 4, mb: 4 }}>
|
||||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
|
||||
Back to Fetch Requests
|
||||
</Button>
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
|
||||
<Chip
|
||||
icon={statusIcons[req.status as FetchRequestStatus] as any}
|
||||
label={req.status.replace(/_/g, " ")}
|
||||
color={statusColors[req.status as FetchRequestStatus]}
|
||||
/>
|
||||
<Typography variant="h6" fontWeight={600}>{req.account_name}</Typography>
|
||||
<Chip
|
||||
label={"path" in req.source ? "File" : "Email"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={"path" in req.source ? "primary" : "secondary"}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Date Range</Typography>
|
||||
<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() : "?"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Created</Typography>
|
||||
<Typography variant="body2">{new Date(req.created_at).toLocaleString()}</Typography>
|
||||
</Box>
|
||||
{req.completed_at && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Completed</Typography>
|
||||
<Typography variant="body2">{new Date(req.completed_at).toLocaleString()}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Box sx={{ flex: 1, maxWidth: 300 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Retries: {retryCount}/{RETRY_MAX}
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(retryCount / RETRY_MAX) * 100}
|
||||
color={isRetryExhausted ? "error" : "primary"}
|
||||
sx={{ mt: 0.5, borderRadius: 1, height: 6 }}
|
||||
/>
|
||||
</Box>
|
||||
{req.status === "failed" && !isRetryExhausted && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<ReplayIcon />}
|
||||
onClick={handleRetry}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{req.status === "failed" && req.error_message && (
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{req.error_message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isRetryExhausted && req.status === "failed" && (
|
||||
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
Max retries reached — no further retry attempts will be made.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
||||
Pipeline Progress
|
||||
</Typography>
|
||||
<Stepper activeStep={activeStep} alternativeLabel>
|
||||
{stepLabels.map((label, index) => {
|
||||
const isCompleted = index < activeStep;
|
||||
const isActive = index === activeStep;
|
||||
const isPaused = req.status === "paused" && isActive;
|
||||
const isFailed = req.status === "failed" && isActive;
|
||||
|
||||
let icon: React.ReactNode;
|
||||
if (isCompleted) {
|
||||
icon = <CheckCircleIcon sx={{ color: "success.main" }} />;
|
||||
} else if (isFailed) {
|
||||
icon = <ErrorIcon sx={{ color: "error.main" }} />;
|
||||
} else if (isPaused) {
|
||||
icon = <WarningAmberIcon sx={{ color: "warning.main" }} />;
|
||||
} else if (isActive) {
|
||||
icon = <CircularProgress size={20} />;
|
||||
} else {
|
||||
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
|
||||
}
|
||||
|
||||
const stepMsg = stepMessages[index];
|
||||
|
||||
return (
|
||||
<Step key={label}>
|
||||
<StepLabel
|
||||
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={600}>{label}</Typography>
|
||||
{stepMsg && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
|
||||
{stepMsg}
|
||||
</Typography>
|
||||
)}
|
||||
</StepLabel>
|
||||
</Step>
|
||||
);
|
||||
})}
|
||||
</Stepper>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
|
||||
Progress Events
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
bgcolor: sseConnected ? "success.main" : "error.main",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{sseConnected ? "Connected" : "Disconnected"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
ref={feedRef}
|
||||
sx={{
|
||||
maxHeight: 300,
|
||||
overflowY: "auto",
|
||||
p: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{sseEvents.length === 0 ? (
|
||||
<Typography variant="body2" color="text.disabled" sx={{ textAlign: "center", py: 2 }}>
|
||||
Waiting for events...
|
||||
</Typography>
|
||||
) : (
|
||||
sseEvents.map((evt, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
>
|
||||
{sseIcon(evt.status)}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{evt.step.replace(/_/g, " ")}
|
||||
</Typography>
|
||||
{evt.message && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{evt.message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{(hasAmbiguities || req.status === "paused") && (
|
||||
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
|
||||
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
||||
Ambiguity Resolution
|
||||
</Typography>
|
||||
|
||||
{ambiguitiesLoading ? (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, py: 2 }}>
|
||||
<CircularProgress size={16} />
|
||||
<Typography variant="body2" color="text.secondary">Loading ambiguities...</Typography>
|
||||
</Box>
|
||||
) : allResolved ? (
|
||||
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
All ambiguities resolved — pipeline will resume on next poll cycle
|
||||
</Alert>
|
||||
) : !hasAmbiguities ? (
|
||||
<Alert severity="info" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
Pipeline paused — no ambiguities found
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
|
||||
Pipeline paused — resolve ambiguities to continue
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasAmbiguities && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{ambiguities.map((ambiguity: any) => {
|
||||
const isResolved = ambiguity.status === "resolved";
|
||||
return (
|
||||
<Paper
|
||||
key={ambiguity.id}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
border: 1,
|
||||
borderColor: isResolved ? "success.main" : "divider",
|
||||
opacity: isResolved ? 0.8 : 1,
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
<Box sx={{ fontFamily: "monospace", fontSize: "0.85rem", mb: 1.5, p: 1, bgcolor: "grey.900", borderRadius: 1, color: "grey.100" }}>
|
||||
{ambiguity.line}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 3, mb: 1.5, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">OCR Amount</Typography>
|
||||
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
|
||||
₹{ambiguity.ocr_amount}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">OCR Balance</Typography>
|
||||
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
|
||||
₹{ambiguity.ocr_balance}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">Previous Balance</Typography>
|
||||
<Typography variant="body2">₹{ambiguity.prev_balance}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{isResolved ? (
|
||||
<Alert severity="success" sx={{ py: 0.5, borderRadius: 2 }} icon={<CheckCircleIcon />}>
|
||||
Resolved: ₹{ambiguity.chosen?.amount} / ₹{ambiguity.chosen?.balance}
|
||||
</Alert>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{ambiguity.candidates.map((candidate: any, ci: number) => {
|
||||
const isCredit = candidate.amount > 0;
|
||||
const isDebit = candidate.amount < 0;
|
||||
const cColor = isCredit ? "success.main" : isDebit ? "error.main" : undefined;
|
||||
return (
|
||||
<Button
|
||||
key={ci}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => handleResolve(ambiguity, candidate)}
|
||||
disabled={resolveMutation.isPending}
|
||||
sx={{
|
||||
borderColor: cColor,
|
||||
color: cColor,
|
||||
"&:hover": cColor ? { borderColor: cColor } : undefined,
|
||||
}}
|
||||
>
|
||||
₹{candidate.amount} / ₹{candidate.balance}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -24,14 +24,28 @@ import {
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Tooltip,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
FormControl,
|
||||
OutlinedInput,
|
||||
Autocomplete,
|
||||
} 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 +55,49 @@ import type {
|
||||
FileSource,
|
||||
EmailSource,
|
||||
} from "./features/fetch-requests";
|
||||
import { RETRY_MAX } from "./features/fetch-requests";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useResourceByName, useConfig } from "../react-openapi";
|
||||
|
||||
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||
pending: "default",
|
||||
processing: "info",
|
||||
paused: "warning",
|
||||
raw_expenses_done: "primary",
|
||||
enriched_done: "warning",
|
||||
completed: "success",
|
||||
failed: "error",
|
||||
};
|
||||
|
||||
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
|
||||
pending: <ScheduleIcon sx={{ fontSize: 16 }} />,
|
||||
processing: <CircularProgress size={14} sx={{ mr: 0.5 }} />,
|
||||
paused: <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />,
|
||||
raw_expenses_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
|
||||
enriched_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
|
||||
completed: <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />,
|
||||
failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />,
|
||||
};
|
||||
|
||||
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 +112,27 @@ export default function FetchRequests() {
|
||||
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null);
|
||||
|
||||
const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList();
|
||||
const [statusFilter, setStatusFilter] = React.useState<string[]>([]);
|
||||
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 { useList: useAccountsList } = useResourceByName("accounts");
|
||||
const { data: accountsData } = useAccountsList();
|
||||
const accountOptions: string[] = React.useMemo(() => {
|
||||
return (accountsData?.data ?? []).map((a: any) => a.name).filter(Boolean);
|
||||
}, [accountsData]);
|
||||
|
||||
const config = useConfig();
|
||||
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
|
||||
const formatOptions: string[] = (fetchRes?.fields?.source?.schema?.format?.options as string[]) ?? ["axis", "icici_ocr"];
|
||||
|
||||
const createMutation = useCreateFetchRequest();
|
||||
const updateMutation = useUpdateFetchRequest();
|
||||
const deleteMutation = useDeleteFetchRequest();
|
||||
const uploadMutation = useUploadFile();
|
||||
|
||||
@@ -106,7 +166,7 @@ export default function FetchRequests() {
|
||||
}
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
const result = await createMutation.mutateAsync({
|
||||
source,
|
||||
account_name: accountName,
|
||||
payor_username: payorUsername,
|
||||
@@ -115,8 +175,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 +197,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 +217,8 @@ export default function FetchRequests() {
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
|
||||
|
||||
return (
|
||||
<Container sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h5" fontWeight="bold" gutterBottom>
|
||||
@@ -189,37 +265,61 @@ export default function FetchRequests() {
|
||||
Uploaded as: {uploadedPath}
|
||||
</Alert>
|
||||
)}
|
||||
<TextField label="Format (csv, pdf, ...)" value={format} onChange={(e) => setFormat(e.target.value)} size="small" />
|
||||
<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="Format" value={format} onChange={(e) => setFormat(e.target.value)} size="small" helperText="e.g. email, pdf, csv" />
|
||||
<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="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
|
||||
<TextField label="Raw Terms" value={rawTerms} onChange={(e) => setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextField label="Account Name" value={accountName} onChange={(e) => setAccountName(e.target.value)} size="small" required />
|
||||
<Autocomplete
|
||||
options={accountOptions}
|
||||
value={accountName || null}
|
||||
onChange={(_, val) => setAccountName(val ?? "")}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="Account Name" size="small" required />
|
||||
)}
|
||||
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
|
||||
/>
|
||||
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<TextField
|
||||
label="Start Date"
|
||||
type="datetime-local"
|
||||
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 }}
|
||||
/>
|
||||
<TextField
|
||||
label="End Date"
|
||||
type="datetime-local"
|
||||
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>
|
||||
@@ -234,79 +334,184 @@ export default function FetchRequests() {
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ borderRadius: 4 }} variant="outlined">
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
Fetch Requests
|
||||
</Typography>
|
||||
<Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined">
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as string[])}
|
||||
input={<OutlinedInput label="Status" />}
|
||||
renderValue={(selected) => (selected as string[]).join(", ")}
|
||||
>
|
||||
{["pending", "processing", "paused", "raw_expenses_done", "enriched_done", "completed", "failed"].map((s) => (
|
||||
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Account"
|
||||
value={accountFilter}
|
||||
onChange={(e) => setAccountFilter(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 160 }}
|
||||
/>
|
||||
<ToggleButtonGroup
|
||||
value={sourceFilter}
|
||||
exclusive
|
||||
onChange={(_, val) => val && setSourceFilter(val)}
|
||||
size="small"
|
||||
>
|
||||
{sourceTypeOptions.map((opt) => (
|
||||
<ToggleButton key={opt} value={opt}>
|
||||
{opt === "all" ? "All" : opt === "file" ? "File" : "Email"}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : requests.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
|
||||
No fetch requests yet
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Fingerprint</TableCell>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>Account</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{requests.map((req: FetchRequest) => (
|
||||
<TableRow key={req.id}>
|
||||
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
{req.fingerprint}
|
||||
{isLoading ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : requests.length === 0 ? (
|
||||
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
|
||||
No fetch requests yet
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Account</TableCell>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>Date Range</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Retries</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{[...requests]
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.map((req: FetchRequest) => (
|
||||
<TableRow
|
||||
key={req.id}
|
||||
hover
|
||||
onClick={() => navigate(`/fetch-requests/${req.id}`)}
|
||||
sx={{ cursor: "pointer", "&:last-child td": { border: 0 } }}
|
||||
>
|
||||
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
{shortId(req.fingerprint)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(req.fingerprint);
|
||||
setSnackbar({ message: "Copied!", severity: "success" });
|
||||
}}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{req.account_name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={"path" in req.source ? "File" : "Email"}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color={"path" in req.source ? "primary" : "secondary"}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
|
||||
{formatDateRange((req as any).start_date, (req as any).end_date)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
|
||||
<Chip
|
||||
icon={statusIcons[req.status] as any}
|
||||
label={req.status.replace(/_/g, " ")}
|
||||
color={statusColors[req.status]}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(req.retry_count ?? 0) > 0 ? (
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||
{req.retry_count}/{RETRY_MAX}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell sx={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>
|
||||
{formatDate(req.created_at)}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
{req.status === "paused" && (
|
||||
<Tooltip title="Resolve ambiguities">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/fetch-requests/${req.id}`);
|
||||
}}
|
||||
>
|
||||
<WarningAmberIcon fontSize="small" color="warning" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
|
||||
<Tooltip title="Retry">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRetry(req);
|
||||
}}
|
||||
>
|
||||
<ReplayIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(req.fingerprint);
|
||||
setSnackbar({ message: "Copied!", severity: "success" });
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(req);
|
||||
}}
|
||||
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
|
||||
>
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{"path" in req.source ? "File" : "Email"}
|
||||
</TableCell>
|
||||
<TableCell>{req.account_name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={req.status.replace(/_/g, " ")}
|
||||
color={statusColors[req.status]}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(req.created_at)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => setDeleteTarget(req)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Paper>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={!!snackbar}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
export type FetchRequestStatus = "pending" | "processing" | "raw_expenses_done" | "enriched_done" | "completed" | "failed";
|
||||
export type FetchRequestStatus =
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "paused"
|
||||
| "raw_expenses_done"
|
||||
| "enriched_done"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export interface FileSource {
|
||||
path: string;
|
||||
format: string;
|
||||
raw_lines?: string[];
|
||||
txn_blocks?: Record<string, any>;
|
||||
txn_dicts?: Record<string, any>[];
|
||||
}
|
||||
|
||||
export interface EmailSource {
|
||||
@@ -20,12 +30,18 @@ export interface FetchRequestCreate {
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
export interface FetchRequestUpdate {
|
||||
status?: FetchRequestStatus;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface FetchRequest extends FetchRequestCreate {
|
||||
id: string;
|
||||
status: FetchRequestStatus;
|
||||
fingerprint: string;
|
||||
completed_at?: string | null;
|
||||
error_message?: string | null;
|
||||
retry_count?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -36,3 +52,44 @@ export interface UploadResult {
|
||||
url: string;
|
||||
absolute_path: string;
|
||||
}
|
||||
|
||||
export interface AmbiguityCandidate {
|
||||
amount: number;
|
||||
balance: number;
|
||||
}
|
||||
|
||||
export interface PendingAmbiguity {
|
||||
id: string;
|
||||
fetch_request: string;
|
||||
step_index?: number;
|
||||
line: string;
|
||||
ocr_amount: number;
|
||||
ocr_balance: number;
|
||||
prev_balance: number;
|
||||
candidates: AmbiguityCandidate[];
|
||||
chosen?: AmbiguityCandidate | null;
|
||||
resolved_at?: string | null;
|
||||
status: "pending" | "resolved";
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ResolveAmbiguityPayload {
|
||||
chosen: {
|
||||
amount: number;
|
||||
balance: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
step: string;
|
||||
status: "started" | "completed" | "paused";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FetchRequestFilters {
|
||||
status?: FetchRequestStatus[];
|
||||
account_name?: string;
|
||||
source_type?: "file" | "email";
|
||||
}
|
||||
|
||||
export const RETRY_MAX = 3;
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
export type {
|
||||
FetchRequest,
|
||||
FetchRequestCreate,
|
||||
FetchRequestUpdate,
|
||||
FetchRequestStatus,
|
||||
FetchRequestFilters,
|
||||
FileSource,
|
||||
EmailSource,
|
||||
UploadResult,
|
||||
PendingAmbiguity,
|
||||
AmbiguityCandidate,
|
||||
ResolveAmbiguityPayload,
|
||||
SSEEvent,
|
||||
} from "./fetch-requests.models";
|
||||
export { RETRY_MAX } from "./fetch-requests.models";
|
||||
export {
|
||||
useFetchRequestsList,
|
||||
useFetchRequest,
|
||||
useCreateFetchRequest,
|
||||
useUpdateFetchRequest,
|
||||
useDeleteFetchRequest,
|
||||
useUploadFile,
|
||||
useFetchRequestAmbiguities,
|
||||
useResolveAmbiguity,
|
||||
} from "./useFetchRequests";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useResourceByName } from "../../../react-openapi";
|
||||
import { api } from "../../../react-openapi/api/client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
|
||||
|
||||
export function useFetchRequestsList(params?: {
|
||||
status?: string;
|
||||
@@ -21,6 +22,11 @@ export function useCreateFetchRequest() {
|
||||
return useCreate();
|
||||
}
|
||||
|
||||
export function useUpdateFetchRequest() {
|
||||
const { usePatch } = useResourceByName("fetch-requests");
|
||||
return usePatch();
|
||||
}
|
||||
|
||||
export function useDeleteFetchRequest() {
|
||||
const { useDelete } = useResourceByName("fetch-requests");
|
||||
return useDelete();
|
||||
@@ -41,3 +47,44 @@ export function useUploadFile() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useFetchRequestAmbiguities(fetchRequestId: string) {
|
||||
return useQuery({
|
||||
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
|
||||
queryFn: async () => {
|
||||
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],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user