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, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, } 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 { useResource, FormFieldRenderer, applyDisplayFormat } from "../react-openapi"; import type { FieldConfig } from "../react-openapi"; import { useMutation, useQuery } from "@tanstack/react-query"; 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: , }; const STATUS_OPTIONS: FetchRequestStatus[] = [ "pending", "processing", "paused", "raw_expenses_done", "enriched_done", "completed", "failed", ]; 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 { list, create, update, remove, resource: fetchRes } = useResource("fetch-requests"); const { data: listData, isLoading, isFetching, refetch } = useQuery({ queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }], queryFn: () => list({ ...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}), ...(accountFilter ? { account_name: accountFilter } : {}), ...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}), }), }); const { list: listAccounts } = useResource("accounts"); const { data: accountsData } = useQuery({ queryKey: ["accounts", "list"], queryFn: () => listAccounts(), }); const accountOptions: string[] = React.useMemo(() => { return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean); }, [accountsData]); const fields = fetchRes?.orderedFields ?? []; const formatField: FieldConfig | undefined = fields.find(f => f.name === "format"); const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date"); const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date"); const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username"); const createMutation = useMutation({ mutationFn: (data: any) => create(data) }); const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data), }); const deleteMutation = useMutation({ mutationFn: (id: string) => remove(id) }); const uploadMutation = useUploadFile(); const requests = listData?.items ?? []; 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 — 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); }; return ( Fetch Request Pipeline {/* Create Form */} 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 && ( )} ) : ( <> {formatField && ( )} 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) => ( )} /> {payorUsernameField && ( )} {startDateField && ( )} {endDateField && ( )} {/* Filters */} Status setAccountFilter(val ?? "")} renderInput={(params) => ( )} sx={{ minWidth: 160 }} /> val && setSourceFilter(val)} size="small" > All File Email refetch()} disabled={isFetching}> {/* List Table */} {isLoading ? ( ) : requests.length === 0 ? ( No fetch requests yet ) : ( ID Account Source Date Range Status Retries Created Actions {[...requests] .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) .map((req: FetchRequest) => ( navigate(`/fetch-requests/${req.id}`)} > {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} {(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.retry_count ?? 0) > 0 ? `${req.retry_count}/${RETRY_MAX}` : "—"} {new Date(req.created_at).toLocaleString()} {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.
); }