fetch requests and reports routes
This commit is contained in:
@@ -23,6 +23,18 @@ import {
|
|||||||
useReport,
|
useReport,
|
||||||
prepareReport,
|
prepareReport,
|
||||||
} from "./features/report";
|
} from "./features/report";
|
||||||
|
import { useResourceByName } from "../react-openapi";
|
||||||
|
|
||||||
|
function formatSnapshotDate(iso: string) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [state, setState] = React.useState<DashboardState>({
|
const [state, setState] = React.useState<DashboardState>({
|
||||||
@@ -42,7 +54,28 @@ export default function Dashboard() {
|
|||||||
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
|
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
|
||||||
const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
|
const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: snapshotsData } = useResourceByName("reports").useList();
|
||||||
|
const snapshotOptions = React.useMemo(() => {
|
||||||
|
const options: { label: string; value: string | null }[] = [
|
||||||
|
{ label: "Latest (auto)", value: null },
|
||||||
|
];
|
||||||
|
if (snapshotsData?.data) {
|
||||||
|
for (const snap of snapshotsData.data) {
|
||||||
|
options.push({
|
||||||
|
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
|
||||||
|
value: snap.snapshot_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, [snapshotsData]);
|
||||||
|
|
||||||
|
const selectedSnapshotOption = snapshotOptions.find((o) => o.value === selectedSnapshotId) ?? snapshotOptions[0];
|
||||||
|
|
||||||
const report = useReport({
|
const report = useReport({
|
||||||
|
snapshot_id: selectedSnapshotId ?? undefined,
|
||||||
periods: ["daily", "weekly", "monthly", "all"],
|
periods: ["daily", "weekly", "monthly", "all"],
|
||||||
flow: state.flow,
|
flow: state.flow,
|
||||||
payee: appliedPayees.length > 0 ? appliedPayees : undefined,
|
payee: appliedPayees.length > 0 ? appliedPayees : undefined,
|
||||||
@@ -265,6 +298,22 @@ export default function Dashboard() {
|
|||||||
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
|
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minWidth: { sm: 220 } }}>
|
||||||
|
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
|
||||||
|
Snapshot
|
||||||
|
</Box>
|
||||||
|
<Autocomplete
|
||||||
|
options={snapshotOptions}
|
||||||
|
value={selectedSnapshotOption}
|
||||||
|
onChange={(_, option) => setSelectedSnapshotId(option?.value ?? null)}
|
||||||
|
getOptionLabel={(o) => o.label}
|
||||||
|
isOptionEqualToValue={(o, v) => o.value === v.value}
|
||||||
|
renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />}
|
||||||
|
sx={{ '& .MuiOutlinedInput-root': { height: 40, py: 0 } }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
|
|||||||
323
src/FetchRequests.tsx
Normal file
323
src/FetchRequests.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
ToggleButton,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Snackbar,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogActions,
|
||||||
|
} 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 {
|
||||||
|
useFetchRequestsList,
|
||||||
|
useCreateFetchRequest,
|
||||||
|
useDeleteFetchRequest,
|
||||||
|
useUploadFile,
|
||||||
|
} from "./features/fetch-requests";
|
||||||
|
import type {
|
||||||
|
FetchRequest,
|
||||||
|
FetchRequestStatus,
|
||||||
|
FileSource,
|
||||||
|
EmailSource,
|
||||||
|
} from "./features/fetch-requests";
|
||||||
|
|
||||||
|
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
|
||||||
|
pending: "default",
|
||||||
|
processing: "info",
|
||||||
|
raw_expenses_done: "primary",
|
||||||
|
enriched_done: "warning",
|
||||||
|
completed: "success",
|
||||||
|
failed: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FetchRequests() {
|
||||||
|
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<File | null>(null);
|
||||||
|
const [uploadedPath, setUploadedPath] = React.useState<string | null>(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<FetchRequest | null>(null);
|
||||||
|
|
||||||
|
const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList();
|
||||||
|
const createMutation = useCreateFetchRequest();
|
||||||
|
const deleteMutation = useDeleteFetchRequest();
|
||||||
|
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 {
|
||||||
|
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();
|
||||||
|
} catch (err: any) {
|
||||||
|
setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setAccountName("");
|
||||||
|
setFormat("");
|
||||||
|
setFile(null);
|
||||||
|
setUploadedPath(null);
|
||||||
|
setFromEmail("");
|
||||||
|
setSubject("");
|
||||||
|
setRawTerms("");
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Container sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h5" fontWeight="bold" gutterBottom>
|
||||||
|
Fetch Request Pipeline
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
||||||
|
New Fetch Request
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={sourceType}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, val) => val && setSourceType(val)}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<ToggleButton value="file">File Upload</ToggleButton>
|
||||||
|
<ToggleButton value="email">Email Fetch</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
{sourceType === "file" ? (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
|
||||||
|
<Button variant="outlined" component="label" startIcon={<CloudUploadIcon />}>
|
||||||
|
Choose File
|
||||||
|
<input type="file" hidden onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||||
|
</Button>
|
||||||
|
<Typography variant="body2" sx={{ flex: 1, color: "text.secondary" }}>
|
||||||
|
{file ? file.name : "No file selected"}
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!file || uploadMutation.isPending}
|
||||||
|
>
|
||||||
|
{uploadMutation.isPending ? "Uploading..." : "Upload"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{uploadedPath && (
|
||||||
|
<Alert severity="success" sx={{ py: 0 }}>
|
||||||
|
Uploaded as: {uploadedPath}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<TextField label="Format (csv, pdf, ...)" value={format} onChange={(e) => setFormat(e.target.value)} size="small" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TextField label="Format" value={format} onChange={(e) => setFormat(e.target.value)} size="small" helperText="e.g. email, pdf, csv" />
|
||||||
|
<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 />
|
||||||
|
<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"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="End Date"
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={createMutation.isPending || !accountName || (sourceType === "file" && (!uploadedPath || !format)) || (sourceType === "email" && !format)}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "Creating..." : "Create Fetch Request"}
|
||||||
|
</Button>
|
||||||
|
</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>
|
||||||
|
<IconButton onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{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>ID</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" }}>
|
||||||
|
{req.id.slice(0, 8)}...
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={!!snackbar}
|
||||||
|
autoHideDuration={4000}
|
||||||
|
onClose={() => setSnackbar(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||||
|
>
|
||||||
|
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete Fetch Request?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
This will permanently delete the fetch request and all associated data.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||||
|
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
|
||||||
|
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
src/ReportSnapshots.tsx
Normal file
271
src/ReportSnapshots.tsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
IconButton,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Snackbar,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogActions,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Chip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import AddCircleIcon from "@mui/icons-material/AddCircle";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import {
|
||||||
|
useReportSnapshotsList,
|
||||||
|
useCreateSnapshot,
|
||||||
|
useDeleteSnapshot,
|
||||||
|
} from "./features/report-snapshots";
|
||||||
|
import type { ReportSnapshot } from "./features/report-snapshots";
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportSnapshots() {
|
||||||
|
const [accounts, setAccounts] = React.useState("");
|
||||||
|
const [ignoreSelf, setIgnoreSelf] = React.useState(false);
|
||||||
|
const [startDate, setStartDate] = React.useState("");
|
||||||
|
const [endDate, setEndDate] = React.useState("");
|
||||||
|
const [minAmount, setMinAmount] = React.useState("");
|
||||||
|
const [maxAmount, setMaxAmount] = React.useState("");
|
||||||
|
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
|
||||||
|
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: listData, isLoading, isFetching, refetch } = useReportSnapshotsList();
|
||||||
|
const createMutation = useCreateSnapshot();
|
||||||
|
const deleteMutation = useDeleteSnapshot();
|
||||||
|
|
||||||
|
const snapshots = listData?.data ?? [];
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
const result = await createMutation.mutateAsync({
|
||||||
|
accounts: accounts.trim() ? accounts.split(",").map((s) => s.trim()).filter(Boolean) : null,
|
||||||
|
ignore_self: ignoreSelf || null,
|
||||||
|
start_date: startDate ? new Date(startDate).toISOString() : null,
|
||||||
|
end_date: endDate ? new Date(endDate).toISOString() : null,
|
||||||
|
min_amount: minAmount ? parseFloat(minAmount) : null,
|
||||||
|
max_amount: maxAmount ? parseFloat(maxAmount) : null,
|
||||||
|
});
|
||||||
|
const snapshotId = (result as any)?.snapshot_id;
|
||||||
|
if (snapshotId) {
|
||||||
|
setCreatedSnapshotId(snapshotId);
|
||||||
|
setSnackbar({ message: `Snapshot created: ${snapshotId}`, severity: "success" });
|
||||||
|
} else {
|
||||||
|
setSnackbar({ message: "Snapshot created", severity: "success" });
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
} catch (err: any) {
|
||||||
|
setSnackbar({ message: err?.response?.data?.detail || "Failed to create snapshot", severity: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setAccounts("");
|
||||||
|
setIgnoreSelf(false);
|
||||||
|
setStartDate("");
|
||||||
|
setEndDate("");
|
||||||
|
setMinAmount("");
|
||||||
|
setMaxAmount("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(deleteTarget.snapshot_id);
|
||||||
|
setSnackbar({ message: "Snapshot deleted", severity: "success" });
|
||||||
|
} catch {
|
||||||
|
setSnackbar({ message: "Failed to delete snapshot", severity: "error" });
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container sx={{ mt: 4, mb: 4 }}>
|
||||||
|
<Typography variant="h5" fontWeight="bold" gutterBottom>
|
||||||
|
Report Snapshots
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
|
||||||
|
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
|
||||||
|
Generate New Snapshot
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Accounts"
|
||||||
|
value={accounts}
|
||||||
|
onChange={(e) => setAccounts(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
helperText="Comma-separated account IDs (leave empty for all)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
|
||||||
|
label="Ignore self-transfers"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Start Date"
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="End Date"
|
||||||
|
type="datetime-local"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Min Amount"
|
||||||
|
type="number"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(e) => setMinAmount(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Max Amount"
|
||||||
|
type="number"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(e) => setMaxAmount(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddCircleIcon />}
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "Generating..." : "Generate Snapshot"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{createdSnapshotId && (
|
||||||
|
<Alert severity="success" onClose={() => setCreatedSnapshotId(null)}>
|
||||||
|
Snapshot created: <strong>{createdSnapshotId}</strong>. Use it in the Dashboard snapshot selector.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</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}>
|
||||||
|
Existing Snapshots
|
||||||
|
</Typography>
|
||||||
|
<IconButton onClick={() => refetch()} disabled={isFetching}>
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : snapshots.length === 0 ? (
|
||||||
|
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
|
||||||
|
No snapshots yet
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Snapshot ID</TableCell>
|
||||||
|
<TableCell>Created</TableCell>
|
||||||
|
<TableCell>Query</TableCell>
|
||||||
|
<TableCell align="right">Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{snapshots.map((snap: ReportSnapshot) => (
|
||||||
|
<TableRow key={snap.id}>
|
||||||
|
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
|
||||||
|
{snap.snapshot_id.slice(0, 12)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(snap.created_at)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{snap.query ? (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
|
||||||
|
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
|
||||||
|
{snap.query.ignore_self && <Chip label="ignore_self" size="small" variant="outlined" />}
|
||||||
|
{snap.query.start_date && <Chip label="start" size="small" variant="outlined" />}
|
||||||
|
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.secondary">—</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={!!snackbar}
|
||||||
|
autoHideDuration={4000}
|
||||||
|
onClose={() => setSnackbar(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||||
|
>
|
||||||
|
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
|
||||||
|
<DialogTitle>Delete Snapshot?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
This will permanently delete the report snapshot.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||||
|
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
|
||||||
|
{deleteMutation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/features/fetch-requests/fetch-requests.models.ts
Normal file
38
src/features/fetch-requests/fetch-requests.models.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export type FetchRequestStatus = "pending" | "processing" | "raw_expenses_done" | "enriched_done" | "completed" | "failed";
|
||||||
|
|
||||||
|
export interface FileSource {
|
||||||
|
path: string;
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailSource {
|
||||||
|
format: string;
|
||||||
|
from_email?: string;
|
||||||
|
subject?: string;
|
||||||
|
raw_terms?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchRequestCreate {
|
||||||
|
source: FileSource | EmailSource;
|
||||||
|
account_name: string;
|
||||||
|
payor_username?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchRequest extends FetchRequestCreate {
|
||||||
|
id: string;
|
||||||
|
status: FetchRequestStatus;
|
||||||
|
fingerprint: string;
|
||||||
|
completed_at?: string | null;
|
||||||
|
error_message?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
original_filename: string;
|
||||||
|
saved_as: string;
|
||||||
|
content_type: string;
|
||||||
|
url: string;
|
||||||
|
absolute_path: string;
|
||||||
|
}
|
||||||
15
src/features/fetch-requests/index.ts
Normal file
15
src/features/fetch-requests/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export type {
|
||||||
|
FetchRequest,
|
||||||
|
FetchRequestCreate,
|
||||||
|
FetchRequestStatus,
|
||||||
|
FileSource,
|
||||||
|
EmailSource,
|
||||||
|
UploadResult,
|
||||||
|
} from "./fetch-requests.models";
|
||||||
|
export {
|
||||||
|
useFetchRequestsList,
|
||||||
|
useFetchRequest,
|
||||||
|
useCreateFetchRequest,
|
||||||
|
useDeleteFetchRequest,
|
||||||
|
useUploadFile,
|
||||||
|
} from "./useFetchRequests";
|
||||||
43
src/features/fetch-requests/useFetchRequests.ts
Normal file
43
src/features/fetch-requests/useFetchRequests.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useResourceByName } from "../../../react-openapi";
|
||||||
|
import { api } from "../../../react-openapi/api/client";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export function useFetchRequestsList(params?: {
|
||||||
|
status?: string;
|
||||||
|
account_name?: string;
|
||||||
|
source_type?: string;
|
||||||
|
}) {
|
||||||
|
const { useList } = useResourceByName("fetch-requests");
|
||||||
|
return useList(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFetchRequest(id: string) {
|
||||||
|
const { useRead } = useResourceByName("fetch-requests");
|
||||||
|
return useRead(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateFetchRequest() {
|
||||||
|
const { useCreate } = useResourceByName("fetch-requests");
|
||||||
|
return useCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteFetchRequest() {
|
||||||
|
const { useDelete } = useResourceByName("fetch-requests");
|
||||||
|
return useDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadFile() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const binary = new Uint8Array(arrayBuffer);
|
||||||
|
const res = await api.post("/uploads", binary, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.type,
|
||||||
|
"Content-Disposition": `attachment; filename="${file.name}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
9
src/features/report-snapshots/index.ts
Normal file
9
src/features/report-snapshots/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type {
|
||||||
|
ReportSnapshot,
|
||||||
|
ReportQuery,
|
||||||
|
} from "./report-snapshots.models";
|
||||||
|
export {
|
||||||
|
useReportSnapshotsList,
|
||||||
|
useCreateSnapshot,
|
||||||
|
useDeleteSnapshot,
|
||||||
|
} from "./useReportSnapshots";
|
||||||
15
src/features/report-snapshots/report-snapshots.models.ts
Normal file
15
src/features/report-snapshots/report-snapshots.models.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export interface ReportQuery {
|
||||||
|
accounts?: string[] | null;
|
||||||
|
ignore_self?: boolean | null;
|
||||||
|
start_date?: string | null;
|
||||||
|
end_date?: string | null;
|
||||||
|
min_amount?: number | null;
|
||||||
|
max_amount?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportSnapshot {
|
||||||
|
id: string;
|
||||||
|
snapshot_id: string;
|
||||||
|
created_at: string;
|
||||||
|
query?: ReportQuery;
|
||||||
|
}
|
||||||
16
src/features/report-snapshots/useReportSnapshots.ts
Normal file
16
src/features/report-snapshots/useReportSnapshots.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useResourceByName } from "../../../react-openapi";
|
||||||
|
|
||||||
|
export function useReportSnapshotsList() {
|
||||||
|
const { useList } = useResourceByName("reports");
|
||||||
|
return useList();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateSnapshot() {
|
||||||
|
const { useCreate } = useResourceByName("reports");
|
||||||
|
return useCreate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSnapshot() {
|
||||||
|
const { useDelete } = useResourceByName("reports");
|
||||||
|
return useDelete();
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Home from './Home';
|
import Home from './Home';
|
||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
|
import FetchRequests from './FetchRequests';
|
||||||
|
import ReportSnapshots from './ReportSnapshots';
|
||||||
import { Admin, AppProvider } from '../react-openapi';
|
import { Admin, AppProvider } from '../react-openapi';
|
||||||
import { configuration, profileConfiguration } from './openapi-config';
|
import { configuration, profileConfiguration } from './openapi-config';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
@@ -33,6 +35,8 @@ const routerMapping = [
|
|||||||
{ path: "/", component: Home, headerTitle: "Home" },
|
{ path: "/", component: Home, headerTitle: "Home" },
|
||||||
{ path: "/home", component: Home, headerTitle: "Home" },
|
{ path: "/home", component: Home, headerTitle: "Home" },
|
||||||
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" },
|
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" },
|
||||||
|
{ path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" },
|
||||||
|
{ path: "/reports", component: ReportSnapshots, headerTitle: "Reports" },
|
||||||
{ path: "/admin/*", component: Admin, headerTitle: "Admin" },
|
{ path: "/admin/*", component: Admin, headerTitle: "Admin" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export const configuration: Record<string, ResourceOverride> = {
|
|||||||
},
|
},
|
||||||
pagination: true,
|
pagination: true,
|
||||||
},
|
},
|
||||||
reports: {
|
// reports: {
|
||||||
hidden: true
|
// hidden: true
|
||||||
}
|
// }
|
||||||
};
|
};
|
||||||
|
|
||||||
export const profileConfiguration = {
|
export const profileConfiguration = {
|
||||||
|
|||||||
Reference in New Issue
Block a user