import * as React from "react"; import { Box, Container, Paper, Typography, Button, ToggleButtonGroup, ToggleButton, Chip, IconButton, CircularProgress, Alert, Snackbar, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Tooltip, TextField, 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 { useUploadFile, } from "./features/fetch-requests"; import type { FetchRequest, FetchRequestStatus, FileSource, EmailSource, } from "./features/fetch-requests"; import { RETRY_MAX, formatApiError } from "./features/fetch-requests"; import { useNavigate } from "react-router-dom"; import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi"; import type { ResourceField } from "../react-openapi"; const statusColors: Record = { pending: "default", processing: "info", paused: "warning", raw_expenses_done: "primary", enriched_done: "warning", completed: "success", failed: "error", }; const statusIcons: Record = { pending: , processing: , paused: , raw_expenses_done: , enriched_done: , completed: , failed: , }; 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) { return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : 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"); const [format, setFormat] = React.useState(""); const [file, setFile] = React.useState(null); const [uploadedPath, setUploadedPath] = React.useState(null); const [fromEmail, setFromEmail] = React.useState(""); const [subject, setSubject] = React.useState(""); const [rawTerms, setRawTerms] = React.useState(""); const [startDate, setStartDate] = React.useState(""); const [endDate, setEndDate] = React.useState(""); const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null); const [deleteTarget, setDeleteTarget] = React.useState(null); const [statusFilter, setStatusFilter] = React.useState([]); const [accountFilter, setAccountFilter] = React.useState(""); const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all"); const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents }); const { data: listData, isLoading, isFetching, refetch } = useList({ ...(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 formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format; const formatOptions: string[] = formatField?.options ?? []; const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date; const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date; const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username; const createMutation = useCreate(); const updateMutation = usePatch(); const deleteMutation = useDelete(); const uploadMutation = useUploadFile(); const requests = listData?.data ?? []; const handleUpload = async () => { if (!file) return; const result = await uploadMutation.mutateAsync(file); if (result?.saved_as) { setUploadedPath(result.saved_as); if (!format) setFormat(file.name.split(".").pop() || ""); setSnackbar({ message: `File uploaded: ${result.saved_as}`, severity: "success" }); } }; const handleCreate = async () => { if (!accountName) return; let source: FileSource | EmailSource; if (sourceType === "file") { if (!uploadedPath || !format) return; source = { path: uploadedPath, format } as FileSource; } else { if (!format) return; const emailSource: EmailSource = { format }; if (fromEmail) emailSource.from_email = fromEmail; if (subject) emailSource.subject = subject; if (rawTerms.trim()) emailSource.raw_terms = rawTerms.split(",").map((s) => s.trim()).filter(Boolean); source = emailSource; } try { const result = await createMutation.mutateAsync({ source, account_name: accountName, payor_username: payorUsername, ...(startDate ? { start_date: new Date(startDate).toISOString() } : {}), ...(endDate ? { end_date: new Date(endDate).toISOString() } : {}), }); setSnackbar({ message: "Fetch request created", severity: "success" }); resetForm(); navigate(`/fetch-requests/${result.id}`); } catch (err: any) { if (err?.response?.status === 409) { setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" }); } else { setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" }); } } }; const resetForm = () => { setAccountName(""); setFormat(""); setFile(null); setUploadedPath(null); setFromEmail(""); setSubject(""); setRawTerms(""); setStartDate(""); 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 { await deleteMutation.mutateAsync(deleteTarget.id); setSnackbar({ message: "Fetch request deleted", severity: "success" }); } catch { setSnackbar({ message: "Failed to delete", severity: "error" }); } setDeleteTarget(null); }; const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"]; return ( Fetch Request Pipeline New Fetch Request val && setSourceType(val)} sx={{ mb: 3 }} size="small" > File Upload Email Fetch {sourceType === "file" ? ( <> {file ? file.name : "No file selected"} {uploadedPath && ( Uploaded as: {uploadedPath} )} {formatField && components?.FormField ? ( ) : ( Format )} ) : ( <> {formatField && components?.FormField ? ( ) : ( Format )} setFromEmail(e.target.value)} size="small" /> setSubject(e.target.value)} size="small" /> setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" /> )} setAccountName(val ?? "")} renderInput={(params) => ( )} sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }} /> {payorUsernameField && components?.FormField ? ( ) : ( setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" /> )} {startDateField && components?.date ? ( ) : ( setStartDate(e.target.value)} size="small" InputLabelProps={{ shrink: true }} inputProps={{ max: new Date().toISOString().split("T")[0] }} sx={{ flex: 1 }} /> )} {endDateField && components?.date ? ( ) : ( setEndDate(e.target.value)} size="small" InputLabelProps={{ shrink: true }} inputProps={{ max: new Date().toISOString().split("T")[0] }} sx={{ flex: 1 }} /> )} Status setAccountFilter(val ?? "")} renderInput={(params) => ( )} sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }} /> val && setSourceFilter(val)} size="small" > {sourceTypeOptions.map((opt) => ( {opt === "all" ? "All" : opt === "file" ? "File" : "Email"} ))} refetch()} disabled={isFetching}> {isLoading ? ( ) : requests.length === 0 ? ( No fetch requests yet ) : ( {["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => ( {h} ))} {[...requests] .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .map((req: FetchRequest) => ( navigate(`/fetch-requests/${req.id}`)} sx={{ cursor: "pointer", borderBottom: 1, borderColor: "divider", "&:hover": { bgcolor: "action.hover" }, "&:last-child": { borderBottom: 0 }, }} > {shortId(req.fingerprint)} { e.stopPropagation(); navigator.clipboard.writeText(req.fingerprint); setSnackbar({ message: "Copied!", severity: "success" }); }} sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }} > {req.account_name} {formatDateRange((req as any).start_date, (req as any).end_date)} {(req.retry_count ?? 0) > 0 ? ( {req.retry_count}/{RETRY_MAX} ) : ( \u2014 )} {formatDate(req.created_at)} {req.status === "paused" && ( { e.stopPropagation(); navigate(`/fetch-requests/${req.id}`); }} > )} {req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && ( { e.stopPropagation(); handleRetry(req); }} > )} { e.stopPropagation(); setDeleteTarget(req); }} > ))} )} setSnackbar(null)} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} > {snackbar ? setSnackbar(null)}>{snackbar.message} : undefined} setDeleteTarget(null)}> Delete Fetch Request? This will permanently delete the fetch request and all associated data. ); }