diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx index 4ac2368..83ce550 100644 --- a/src/Dashboard.tsx +++ b/src/Dashboard.tsx @@ -23,6 +23,18 @@ import { useReport, prepareReport, } 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() { const [state, setState] = React.useState({ @@ -42,7 +54,28 @@ export default function Dashboard() { const [loadedPayees, setLoadedPayees] = React.useState([]); const [loadedTags, setLoadedTags] = React.useState([]); + const [selectedSnapshotId, setSelectedSnapshotId] = React.useState(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({ + snapshot_id: selectedSnapshotId ?? undefined, periods: ["daily", "weekly", "monthly", "all"], flow: state.flow, 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 } }} /> + + + Snapshot + + setSelectedSnapshotId(option?.value ?? null)} + getOptionLabel={(o) => o.label} + isOptionEqualToValue={(o, v) => o.value === v.value} + renderInput={(params) => } + sx={{ '& .MuiOutlinedInput-root': { height: 40, py: 0 } }} + size="small" + /> + + + + {file ? file.name : "No file selected"} + + + + {uploadedPath && ( + + Uploaded as: {uploadedPath} + + )} + setFormat(e.target.value)} size="small" /> + + ) : ( + <> + setFormat(e.target.value)} size="small" helperText="e.g. email, pdf, csv" /> + setFromEmail(e.target.value)} size="small" /> + setSubject(e.target.value)} size="small" /> + setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" /> + + )} + + setAccountName(e.target.value)} size="small" required /> + setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" /> + + + setStartDate(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + sx={{ flex: 1 }} + /> + setEndDate(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + sx={{ flex: 1 }} + /> + + + + + + + + + + Fetch Requests + + refetch()} disabled={isFetching}> + + + + + {isLoading ? ( + + + + ) : requests.length === 0 ? ( + + No fetch requests yet + + ) : ( + + + + + ID + Source + Account + Status + Created + Actions + + + + {requests.map((req: FetchRequest) => ( + + + {req.id.slice(0, 8)}... + + + {"path" in req.source ? "File" : "Email"} + + {req.account_name} + + + + {formatDate(req.created_at)} + + 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. + + + + + + + + + ); +} diff --git a/src/ReportSnapshots.tsx b/src/ReportSnapshots.tsx new file mode 100644 index 0000000..e70bb2f --- /dev/null +++ b/src/ReportSnapshots.tsx @@ -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(null); + const [createdSnapshotId, setCreatedSnapshotId] = React.useState(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 ( + + + Report Snapshots + + + + + Generate New Snapshot + + + + setAccounts(e.target.value)} + size="small" + helperText="Comma-separated account IDs (leave empty for all)" + /> + + setIgnoreSelf(e.target.checked)} />} + label="Ignore self-transfers" + /> + + + setStartDate(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + sx={{ flex: 1 }} + /> + setEndDate(e.target.value)} + size="small" + InputLabelProps={{ shrink: true }} + sx={{ flex: 1 }} + /> + + + + setMinAmount(e.target.value)} + size="small" + sx={{ flex: 1 }} + /> + setMaxAmount(e.target.value)} + size="small" + sx={{ flex: 1 }} + /> + + + + + {createdSnapshotId && ( + setCreatedSnapshotId(null)}> + Snapshot created: {createdSnapshotId}. Use it in the Dashboard snapshot selector. + + )} + + + + + + + Existing Snapshots + + refetch()} disabled={isFetching}> + + + + + {isLoading ? ( + + + + ) : snapshots.length === 0 ? ( + + No snapshots yet + + ) : ( + + + + + Snapshot ID + Created + Query + Actions + + + + {snapshots.map((snap: ReportSnapshot) => ( + + + {snap.snapshot_id.slice(0, 12)}... + + {formatDate(snap.created_at)} + + {snap.query ? ( + + {snap.query.accounts && } + {snap.query.ignore_self && } + {snap.query.start_date && } + {snap.query.end_date && } + + ) : ( + + )} + + + setDeleteTarget(snap)}> + + + + + ))} + +
+
+ )} +
+ + setSnackbar(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + {snackbar ? setSnackbar(null)}>{snackbar.message} : undefined} + + + setDeleteTarget(null)}> + Delete Snapshot? + + + This will permanently delete the report snapshot. + + + + + + + +
+ ); +} diff --git a/src/features/fetch-requests/fetch-requests.models.ts b/src/features/fetch-requests/fetch-requests.models.ts new file mode 100644 index 0000000..0f9084c --- /dev/null +++ b/src/features/fetch-requests/fetch-requests.models.ts @@ -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; +} diff --git a/src/features/fetch-requests/index.ts b/src/features/fetch-requests/index.ts new file mode 100644 index 0000000..0af66e1 --- /dev/null +++ b/src/features/fetch-requests/index.ts @@ -0,0 +1,15 @@ +export type { + FetchRequest, + FetchRequestCreate, + FetchRequestStatus, + FileSource, + EmailSource, + UploadResult, +} from "./fetch-requests.models"; +export { + useFetchRequestsList, + useFetchRequest, + useCreateFetchRequest, + useDeleteFetchRequest, + useUploadFile, +} from "./useFetchRequests"; diff --git a/src/features/fetch-requests/useFetchRequests.ts b/src/features/fetch-requests/useFetchRequests.ts new file mode 100644 index 0000000..7b20ccc --- /dev/null +++ b/src/features/fetch-requests/useFetchRequests.ts @@ -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; + }, + }); +} diff --git a/src/features/report-snapshots/index.ts b/src/features/report-snapshots/index.ts new file mode 100644 index 0000000..9350c75 --- /dev/null +++ b/src/features/report-snapshots/index.ts @@ -0,0 +1,9 @@ +export type { + ReportSnapshot, + ReportQuery, +} from "./report-snapshots.models"; +export { + useReportSnapshotsList, + useCreateSnapshot, + useDeleteSnapshot, +} from "./useReportSnapshots"; diff --git a/src/features/report-snapshots/report-snapshots.models.ts b/src/features/report-snapshots/report-snapshots.models.ts new file mode 100644 index 0000000..4bf5698 --- /dev/null +++ b/src/features/report-snapshots/report-snapshots.models.ts @@ -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; +} diff --git a/src/features/report-snapshots/useReportSnapshots.ts b/src/features/report-snapshots/useReportSnapshots.ts new file mode 100644 index 0000000..4547629 --- /dev/null +++ b/src/features/report-snapshots/useReportSnapshots.ts @@ -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(); +} diff --git a/src/main.jsx b/src/main.jsx index 503363c..0b80d58 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -12,6 +12,8 @@ import { } from "@mui/material"; import Home from './Home'; import Dashboard from './Dashboard'; +import FetchRequests from './FetchRequests'; +import ReportSnapshots from './ReportSnapshots'; import { Admin, AppProvider } from '../react-openapi'; import { configuration, profileConfiguration } from './openapi-config'; import { Buffer } from 'buffer'; @@ -33,6 +35,8 @@ const routerMapping = [ { path: "/", component: Home, headerTitle: "Home" }, { path: "/home", component: Home, headerTitle: "Home" }, { 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" }, ]; diff --git a/src/openapi-config.ts b/src/openapi-config.ts index 5a1f39c..7af32b0 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -40,9 +40,9 @@ export const configuration: Record = { }, pagination: true, }, - reports: { - hidden: true - } + // reports: { + // hidden: true + // } }; export const profileConfiguration = {