fetch request steps

This commit is contained in:
2026-05-28 17:52:53 +05:30
parent d4a79c785d
commit c920276293
8 changed files with 807 additions and 68 deletions

View File

@@ -24,14 +24,27 @@ import {
DialogContent,
DialogContentText,
DialogActions,
Tooltip,
Select,
MenuItem,
InputLabel,
FormControl,
OutlinedInput,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import ScheduleIcon from "@mui/icons-material/Schedule";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import {
useFetchRequestsList,
useCreateFetchRequest,
useUpdateFetchRequest,
useDeleteFetchRequest,
useUploadFile,
} from "./features/fetch-requests";
@@ -41,22 +54,48 @@ import type {
FileSource,
EmailSource,
} from "./features/fetch-requests";
import { RETRY_MAX } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
const statusColors: Record<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 +110,17 @@ 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 createMutation = useCreateFetchRequest();
const updateMutation = useUpdateFetchRequest();
const deleteMutation = useDeleteFetchRequest();
const uploadMutation = useUploadFile();
@@ -106,7 +154,7 @@ export default function FetchRequests() {
}
try {
await createMutation.mutateAsync({
const result = await createMutation.mutateAsync({
source,
account_name: accountName,
payor_username: payorUsername,
@@ -115,8 +163,13 @@ export default function FetchRequests() {
});
setSnackbar({ message: "Fetch request created", severity: "success" });
resetForm();
navigate(`/fetch-requests/${result.id}`);
} catch (err: any) {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" });
if (err?.response?.status === 409) {
setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" });
} else {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" });
}
}
};
@@ -132,6 +185,15 @@ export default function FetchRequests() {
setEndDate("");
};
const handleRetry = async (req: FetchRequest) => {
try {
await updateMutation.mutateAsync({ id: req.id, data: { status: "pending" } });
setSnackbar({ message: "Retrying fetch request", severity: "success" });
} catch {
setSnackbar({ message: "Failed to retry", severity: "error" });
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
@@ -143,6 +205,8 @@ export default function FetchRequests() {
setDeleteTarget(null);
};
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
@@ -234,79 +298,171 @@ 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 === "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}