From cccb4604fd063a299781de0ed12f3ec4cd169452 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 23 May 2026 17:22:30 +0530 Subject: [PATCH 01/20] fixes for latest openapi spec changes --- react-openapi/hooks/useResource.ts | 6 +++--- src/Dashboard.tsx | 10 +++++----- src/features/report/useReport.ts | 13 ++++++++----- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/react-openapi/hooks/useResource.ts b/react-openapi/hooks/useResource.ts index 715332d..1d6c388 100644 --- a/react-openapi/hooks/useResource.ts +++ b/react-openapi/hooks/useResource.ts @@ -30,13 +30,13 @@ export function useResource(config: ResourceConfig | undefined) { }); // --- READ ONE --- - const useRead = (id: string | null) => + const useRead = (id: string, params?: any | null) => useQuery({ - queryKey: [name, "detail", id], + queryKey: [name, "detail", id, params], queryFn: async () => { if (!id || !endpoint) return null; // @ts-ignore - const res = await api.get(`${endpoint}/${id}`); + const res = await api.get(`${endpoint}/${id}`, params ? { params } : undefined); return res.data; }, enabled: !!id && !!endpoint, diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx index 04a5795..4ac2368 100644 --- a/src/Dashboard.tsx +++ b/src/Dashboard.tsx @@ -50,10 +50,10 @@ export default function Dashboard() { }); React.useEffect(() => { - if (report.data?.data) { + if (report.data) { setLoadedPayees(prev => { const pSet = new Set(prev); - report.data.data.buckets.forEach((b: any) => { + report.data.buckets.forEach((b: any) => { Object.values(b.periods).forEach((periodArray: any) => { periodArray?.forEach((p: any) => { p.metric?.transactions?.forEach((t: any) => { @@ -67,7 +67,7 @@ export default function Dashboard() { setLoadedTags(prev => { const tSet = new Set(prev); - report.data.data.buckets.forEach((b: any) => { + report.data.buckets.forEach((b: any) => { Object.values(b.periods).forEach((periodArray: any) => { periodArray?.forEach((p: any) => { p.metric?.transactions?.forEach((t: any) => { @@ -79,7 +79,7 @@ export default function Dashboard() { return Array.from(tSet).sort(); }); } - }, [report.data?.data]); + }, [report.data]); const toggleFlow = React.useCallback(() => { @@ -219,7 +219,7 @@ export default function Dashboard() { return null; } - const data = prepareReport(report.data.data); + const data = prepareReport(report.data); return ( diff --git a/src/features/report/useReport.ts b/src/features/report/useReport.ts index 1ffea4d..d9d2040 100644 --- a/src/features/report/useReport.ts +++ b/src/features/report/useReport.ts @@ -9,10 +9,13 @@ export interface ReportParams { } export function useReport(params: ReportParams) { - const { useList } = useResourceByName("reports"); + const { useRead } = useResourceByName("reports"); - return useList({ - ...params, - periods: params.periods, - }); + return useRead( + params.snapshot_id ? params.snapshot_id : "latest", + { + ...params, + periods: params.periods, + } + ); } -- 2.49.1 From 220c84776f58e9224534d4bea3f7254af8324cb8 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 23 May 2026 21:48:39 +0530 Subject: [PATCH 02/20] fetch requests and reports routes --- src/Dashboard.tsx | 49 +++ src/FetchRequests.tsx | 323 ++++++++++++++++++ src/ReportSnapshots.tsx | 271 +++++++++++++++ .../fetch-requests/fetch-requests.models.ts | 38 +++ src/features/fetch-requests/index.ts | 15 + .../fetch-requests/useFetchRequests.ts | 43 +++ src/features/report-snapshots/index.ts | 9 + .../report-snapshots.models.ts | 15 + .../report-snapshots/useReportSnapshots.ts | 16 + src/main.jsx | 4 + src/openapi-config.ts | 6 +- 11 files changed, 786 insertions(+), 3 deletions(-) create mode 100644 src/FetchRequests.tsx create mode 100644 src/ReportSnapshots.tsx create mode 100644 src/features/fetch-requests/fetch-requests.models.ts create mode 100644 src/features/fetch-requests/index.ts create mode 100644 src/features/fetch-requests/useFetchRequests.ts create mode 100644 src/features/report-snapshots/index.ts create mode 100644 src/features/report-snapshots/report-snapshots.models.ts create mode 100644 src/features/report-snapshots/useReportSnapshots.ts 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 = { -- 2.49.1 From 3a72985efbee47f03ff45f3004acd80047e17cd0 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 23 May 2026 22:18:18 +0530 Subject: [PATCH 03/20] home changes for new routes --- src/Header.tsx | 26 ++++++ src/Home.tsx | 226 +++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 208 insertions(+), 44 deletions(-) diff --git a/src/Header.tsx b/src/Header.tsx index d7aca2c..f25baf6 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -91,6 +91,32 @@ export default function Header({ + {/* NAV LINKS */} + + {[ + { label: "Dashboard", path: "/dashboard" }, + { label: "Fetch", path: "/fetch-requests" }, + { label: "Reports", path: "/reports" }, + ].map(({ label, path }) => ( + + ))} + + {/* AUTH SECTION */} {isAuthenticated ? ( <> diff --git a/src/Home.tsx b/src/Home.tsx index e4aa1a7..8cb870c 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -1,71 +1,180 @@ import * as React from "react"; -import { Box, Typography, Button, Container, Stack } from "@mui/material"; +import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material"; import { useTheme, alpha } from "@mui/material/styles"; import { useNavigate } from "react-router-dom"; +import DashboardIcon from "@mui/icons-material/Dashboard"; +import SyncIcon from "@mui/icons-material/Sync"; +import BarChartIcon from "@mui/icons-material/BarChart"; +import SettingsIcon from "@mui/icons-material/Settings"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import { useAuth } from "../react-auth"; -export default function Home() { +interface FeatureCardProps { + icon: React.ReactNode; + title: string; + description: string; + path: string; + label?: string; + accent: string; +} + +function FeatureCard({ icon, title, description, path, label, accent }: FeatureCardProps) { const navigate = useNavigate(); const theme = useTheme(); + return ( + navigate(path)} + sx={{ + p: 3, + borderRadius: 3, + border: "1px solid", + borderColor: "divider", + cursor: "pointer", + height: "100%", + display: "flex", + flexDirection: "column", + position: "relative", + overflow: "hidden", + transition: "all 0.25s ease", + "&::before": { + content: '""', + position: "absolute", + top: 0, + left: 0, + right: 0, + height: 3, + background: accent, + opacity: 0, + transition: "opacity 0.25s ease", + }, + "&:hover": { + transform: "translateY(-4px)", + boxShadow: `0 12px 32px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.3 : 0.08)}`, + borderColor: "transparent", + "&::before": { opacity: 1 }, + }, + }} + > + + + {icon} + + + {title} + + + + + {description} + + + {label && ( + + )} + + ); +} + +export default function Home() { + const navigate = useNavigate(); + const theme = useTheme(); + const { currentUser } = useAuth(); + + const features = [ + { + icon: , + title: "Dashboard", + description: "Visualise inflows and outflows with interactive charts, drill into categories, and track trends over daily, weekly, and monthly periods.", + path: "/dashboard", + accent: theme.palette.mode === "dark" ? "#818cf8" : "#6366f1", + }, + { + icon: , + title: "Fetch Requests", + description: "Upload bank statements or configure email ingestion to auto-import transactions. Track pipeline status from pending through to completion.", + path: "/fetch-requests", + accent: theme.palette.mode === "dark" ? "#34d399" : "#10b981", + }, + { + icon: , + title: "Report Snapshots", + description: "Generate cached report snapshots with custom filters — accounts, date ranges, amount bounds — then pin a snapshot on the dashboard for consistent comparisons.", + path: "/reports", + accent: theme.palette.mode === "dark" ? "#fbbf24" : "#f59e0b", + }, + { + icon: , + title: "Admin", + description: "Full CRUD over accounts, expenses, tags, and payors. Manage your data programmatically through the OpenAPI-driven admin panel.", + path: "/admin", + accent: theme.palette.mode === "dark" ? "#e879f9" : "#d946ef", + }, + ]; + return ( - - + alpha(t.palette.common.white, t.palette.mode === "dark" ? 0.04 : 0.6), - border: "1px solid", - borderColor: "divider", - borderRadius: 4, - boxShadow: (t) => - t.palette.mode === "dark" - ? "0 8px 32px 0 rgba(0, 0, 0, 0.5)" - : "0 8px 32px 0 rgba(31, 38, 135, 0.07)", + textAlign: "center", + mb: 6, }} > @@ -73,14 +182,20 @@ export default function Home() { - Your intelligent, extensible financial ledger. Control accounts, manage transactions, and track your data dynamically with our OpenAPI-driven architecture. + Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow. - + + - + + + + {features.map((f) => ( + + + + ))} + ); -- 2.49.1 From 7ab5ce74b3d3f1aff9b67fcbfbd10d2626b0c3ff Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 00:59:33 +0530 Subject: [PATCH 04/20] filter in admin --- react-openapi/components/EnhancedTable.tsx | 38 +-- react-openapi/components/FilterBar.tsx | 286 +++++++++++++++++++++ react-openapi/components/ResourceView.tsx | 130 ++++++++-- react-openapi/index.ts | 3 +- react-openapi/types/config.ts | 4 + react-openapi/types/overrides.ts | 2 + react-openapi/utils/openapi_loader.ts | 4 +- src/openapi-config.ts | 6 +- 8 files changed, 431 insertions(+), 42 deletions(-) create mode 100644 react-openapi/components/FilterBar.tsx diff --git a/react-openapi/components/EnhancedTable.tsx b/react-openapi/components/EnhancedTable.tsx index 9e00a1c..434a4cc 100644 --- a/react-openapi/components/EnhancedTable.tsx +++ b/react-openapi/components/EnhancedTable.tsx @@ -49,8 +49,8 @@ export default function EnhancedTable({ config, data, total, - paginationModel, - onPaginationModelChange, + paginationModel: externalPaginationModel, + onPaginationModelChange: externalOnPaginationModelChange, loading = false, onEdit, onDelete, @@ -60,6 +60,14 @@ export default function EnhancedTable({ const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); const navigate = useNavigate(); + + const isServer = config.mode !== "client"; + const [internalPaginationModel, setInternalPaginationModel] = React.useState({ + page: 0, + pageSize: 10, + }); + const paginationModel = isServer ? externalPaginationModel : internalPaginationModel; + const onPaginationModelChange = isServer ? externalOnPaginationModelChange : setInternalPaginationModel; const columns: GridColDef[] = React.useMemo(() => { const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => { @@ -161,20 +169,18 @@ export default function EnhancedTable({ rows={data || []} columns={columns} autoHeight - paginationMode={config.pagination ? 'server' : 'client'} - rowCount={(() => { - if (!config.pagination) return data.length; - if (total !== undefined) return total; - - // Graceful fallback for missing total count - const page = paginationModel?.page || 0; - const pageSize = paginationModel?.pageSize || 10; - if (data.length < pageSize) { - return page * pageSize + data.length; - } - // Enable 'Next' button by pretending there's at least one more page - return (page + 2) * pageSize; - })()} + paginationMode={isServer ? 'server' : 'client'} + {...(isServer ? { + rowCount: (() => { + if (total !== undefined) return total; + const page = paginationModel?.page || 0; + const pageSize = paginationModel?.pageSize || 10; + if (data.length < pageSize) { + return page * pageSize + data.length; + } + return (page + 2) * pageSize; + })(), + } : {})} loading={loading} paginationModel={paginationModel || { page: 0, pageSize: 10 }} onPaginationModelChange={onPaginationModelChange} diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx new file mode 100644 index 0000000..bf8af63 --- /dev/null +++ b/react-openapi/components/FilterBar.tsx @@ -0,0 +1,286 @@ +import * as React from "react"; +import { + Box, + Button, + Paper, + TextField, + Autocomplete, + Select, + MenuItem, + FormControl, + InputLabel, + Typography, +} from "@mui/material"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import { ResourceField, ResourceMode } from "../types/config"; + +function getDisplayValue(item: any, field: ResourceField): string { + if (!item) return ""; + const df = field.displayField; + if (!df) return item.name || item.title || item.label || String(item.id ?? ""); + if (Array.isArray(df)) { + return df.map((k) => item[k]).filter((v) => v != null).join(" "); + } + return item[df] ?? String(item.id ?? ""); +} + +function extractOptions( + fieldName: string, + field: ResourceField, + data: any[] +): string[] { + const values = new Set(); + + if (field.options) { + return field.options; + } + + if (!data) return []; + + for (const item of data) { + const v = item[fieldName]; + if (v == null) continue; + + if (field.type === "array" && Array.isArray(v)) { + for (const el of v) { + if (el != null && typeof el === "object") { + const d = getDisplayValue(el, field); + if (d) values.add(d); + } else if (el != null) { + values.add(String(el)); + } + } + } else if (typeof v === "object") { + const d = getDisplayValue(v, field); + if (d) values.add(d); + } else { + values.add(String(v)); + } + } + + return Array.from(values).sort(); +} + +function renderFilterInput( + fieldName: string, + field: ResourceField, + options: string[], + value: any, + onChange: (key: string, val: any) => void +) { + const isRange = + field.type === "number" || field.type === "datetime" || field.type === "date"; + + if (isRange) { + const rangeVal = (value as { min?: string; max?: string; start?: string; end?: string }) || {}; + const isDate = field.type === "datetime" || field.type === "date"; + const inputType = isDate ? "datetime-local" : "number"; + + if (isDate) { + return ( + + + {field.label} + + onChange("start", e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + sx={{ width: 190 }} + /> + onChange("end", e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + sx={{ width: 190 }} + /> + + ); + } + + return ( + + + {field.label} + + onChange("min", e.target.value || undefined)} + sx={{ width: 120 }} + /> + onChange("max", e.target.value || undefined)} + sx={{ width: 120 }} + /> + + ); + } + + if (field.type === "boolean") { + return ( + + {field.label} + + + ); + } + + if (options.length <= 20) { + return ( + onChange("value", val || undefined)} + renderInput={(params) => ( + + )} + sx={{ minWidth: 180 }} + size="small" + /> + ); + } + + return ( + onChange("value", e.target.value || undefined)} + size="small" + sx={{ minWidth: 180 }} + /> + ); +} + +export interface FilterBarProps { + fields: Record; + filterableFields: string[]; + mode: ResourceMode; + data?: any[]; + appliedValues: Record; + onApply: (values: Record) => void; + onClear: () => void; +} + +export default function FilterBar({ + fields, + filterableFields, + data, + appliedValues, + onApply, + onClear, +}: FilterBarProps) { + const [open, setOpen] = React.useState(false); + const [draft, setDraft] = React.useState>(() => ({ ...appliedValues })); + + React.useEffect(() => { + if (!open) setDraft({ ...appliedValues }); + }, [appliedValues, open]); + + if (!filterableFields || filterableFields.length === 0) return null; + + const activeCount = Object.keys(appliedValues).filter((k) => { + const v = appliedValues[k]; + if (v == null || v === "") return false; + if (typeof v === "object" && Object.values(v).every((x) => x == null || x === "")) return false; + return true; + }).length; + + const handleApply = () => onApply({ ...draft }); + const handleClear = () => { + setDraft({}); + onClear(); + }; + + const updateDraft = (fieldName: string, key: string, val: any) => { + setDraft((prev) => { + if (key === "value") { + return { ...prev, [fieldName]: val }; + } + const existing = prev[fieldName] || {}; + return { ...prev, [fieldName]: { ...existing, [key]: val } }; + }); + }; + + return ( + + setOpen((o) => !o)} + > + + + + {open ? "Hide Filters" : "Show Filters"} + + + {activeCount > 0 && ( + + {activeCount} active + + )} + + + {open && ( + + + {filterableFields.map((fieldName) => { + const field = fields[fieldName]; + if (!field) return null; + + const options = extractOptions(fieldName, field, data ?? []); + const raw = draft[fieldName]; + + return ( + + {renderFilterInput(fieldName, field, options, raw, (key, val) => + updateDraft(fieldName, key, val) + )} + + ); + })} + + + + + + + + )} + + ); +} diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx index f770d45..4c69014 100644 --- a/react-openapi/components/ResourceView.tsx +++ b/react-openapi/components/ResourceView.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { Box, Typography, Paper, CircularProgress } from '@mui/material'; +import { Box, Paper, CircularProgress } from '@mui/material'; import { ResourceConfig } from '../types/config'; +import type { ResourceField } from '../types/config'; import { useResource } from '../hooks/useResource'; import GenericForm from './GenericForm'; import EnhancedTable from './EnhancedTable'; -import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom'; +import FilterBar from './FilterBar'; +import { useParams, useLocation, useNavigate } from 'react-router-dom'; interface ResourceViewProps { config: ResourceConfig; @@ -13,36 +15,111 @@ interface ResourceViewProps { import { GridPaginationModel } from '@mui/x-data-grid'; +function getFilterDisplayFields(field: ResourceField): string[] { + if (!field.displayField) return ["name", "title", "label"]; + return (Array.isArray(field.displayField) ? field.displayField : [field.displayField]).filter( + (df): df is string => !!df + ); +} + +function applyClientFilters( + data: any[], + filters: Record, + fields: Record +): any[] { + const entries = Object.entries(filters).filter(([_, v]) => { + if (v == null || v === "") return false; + if (typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => x == null || x === "")) return false; + return true; + }); + + if (entries.length === 0) return data; + + return data.filter((item) => + entries.every(([fieldName, filterValue]) => { + const field = fields[fieldName]; + if (!field) return true; + + const itemValue = item[fieldName]; + + if (typeof filterValue === "object" && !Array.isArray(filterValue)) { + if (field.type === "number") { + if (filterValue.min != null && filterValue.min !== "" && Number(itemValue) < Number(filterValue.min)) return false; + if (filterValue.max != null && filterValue.max !== "" && Number(itemValue) > Number(filterValue.max)) return false; + return true; + } + if (field.type === "datetime" || field.type === "date") { + const itemTime = new Date(itemValue).getTime(); + if (filterValue.start && new Date(filterValue.start).getTime() > itemTime) return false; + if (filterValue.end && new Date(filterValue.end).getTime() < itemTime) return false; + return true; + } + return true; + } + + if (!filterValue) return true; + + if (field.type === "boolean") { + return String(itemValue) === filterValue; + } + + if (field.type === "array" && Array.isArray(itemValue) && field.relation) { + const dispFields = getFilterDisplayFields(field); + return itemValue.some((el: any) => + dispFields.some((df) => String(el[df]) === String(filterValue)) + ); + } + + if (field.relation && itemValue && typeof itemValue === "object") { + const dispFields = getFilterDisplayFields(field); + return dispFields.some((df) => String(itemValue[df]) === String(filterValue)); + } + + return String(itemValue) === String(filterValue); + }) + ); +} + export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) { const { id } = useParams(); const location = useLocation(); const navigate = useNavigate(); - + const isCreate = location.pathname.endsWith('/create'); const isEdit = location.pathname.includes('/edit/'); const isView = !!id && !isEdit; const isList = !id && !isCreate; + const isServer = config.mode !== "client"; + const [paginationModel, setPaginationModel] = React.useState({ page: 0, pageSize: 10, }); + const [appliedFilters, setAppliedFilters] = React.useState>({}); + const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config); - // Determine query parameters based on pagination config const queryParams = React.useMemo(() => { - if (!config.pagination) return {}; + if (!isServer) return { limit: 10000 }; return { skip: paginationModel.page * paginationModel.pageSize, limit: paginationModel.pageSize, }; - }, [config.pagination, paginationModel]); + }, [isServer, paginationModel]); const listQuery = useList(queryParams); const itemQuery = useRead(id || ""); - - const paginatedData = listQuery.data || { data: [], total: undefined }; + + const rawData = listQuery.data?.data || []; + const totalCount = listQuery.data?.total; + + const filteredData = React.useMemo( + () => (isServer ? rawData : applyClientFilters(rawData, appliedFilters, config.fields)), + [isServer, rawData, appliedFilters, config.fields] + ); + const createMutation = useCreate(); const updateMutation = useUpdate(); const deleteMutation = useDelete(); @@ -80,18 +157,31 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV return ( {isList ? ( - navigate(`/admin/${res}/${id}`)} - /> + + {!isServer && config.filterableFields && config.filterableFields.length > 0 && ( + setAppliedFilters({})} + /> + )} + navigate(`/admin/${res}/${id}`)} + /> + ) : ( ; pagination?: boolean; hidden?: boolean; + mode: ResourceMode; + filterableFields?: string[]; } export interface AppConfig { diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts index 6a37d10..bb11784 100644 --- a/react-openapi/types/overrides.ts +++ b/react-openapi/types/overrides.ts @@ -13,4 +13,6 @@ export interface ResourceOverride { fields?: Record; pagination?: boolean; hidden?: boolean; + mode?: "server" | "client"; + filterableFields?: string[]; } diff --git a/react-openapi/utils/openapi_loader.ts b/react-openapi/utils/openapi_loader.ts index 112f74c..cd91a1d 100644 --- a/react-openapi/utils/openapi_loader.ts +++ b/react-openapi/utils/openapi_loader.ts @@ -159,10 +159,12 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco label: schema.title || label, pluralLabel: pluralLabel, endpoint: listPath, - primaryKey: "id", // Strict default, no heuristics + primaryKey: "id", fields, pagination: resourceOverride.pagination, hidden: resourceOverride.hidden, + mode: resourceOverride.mode || "server", + filterableFields: resourceOverride.filterableFields, }); } diff --git a/src/openapi-config.ts b/src/openapi-config.ts index 7af32b0..d837480 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -2,6 +2,8 @@ import { ResourceOverride } from "../react-openapi/types/overrides"; export const configuration: Record = { expenses: { + mode: "client", + filterableFields: ["payee", "account", "tags", "occurred_at", "amount"], fields: { payee: { displayField: "name", @@ -38,11 +40,7 @@ export const configuration: Record = { display: false } }, - pagination: true, }, - // reports: { - // hidden: true - // } }; export const profileConfiguration = { -- 2.49.1 From a3970d6a7bc38639f7f5a0881682524e88bb9822 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 01:21:51 +0530 Subject: [PATCH 05/20] filter in config and fixes --- react-openapi/components/FilterBar.tsx | 159 ++++++++++------------ react-openapi/components/ResourceView.tsx | 26 +++- react-openapi/types/config.ts | 1 + react-openapi/types/overrides.ts | 1 + src/openapi-config.ts | 7 + 5 files changed, 101 insertions(+), 93 deletions(-) diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index bf8af63..d9f226d 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -14,16 +14,6 @@ import { import FilterListIcon from "@mui/icons-material/FilterList"; import { ResourceField, ResourceMode } from "../types/config"; -function getDisplayValue(item: any, field: ResourceField): string { - if (!item) return ""; - const df = field.displayField; - if (!df) return item.name || item.title || item.label || String(item.id ?? ""); - if (Array.isArray(df)) { - return df.map((k) => item[k]).filter((v) => v != null).join(" "); - } - return item[df] ?? String(item.id ?? ""); -} - function extractOptions( fieldName: string, field: ResourceField, @@ -31,33 +21,45 @@ function extractOptions( ): string[] { const values = new Set(); - if (field.options) { - return field.options; - } - + if (field.options) return field.options; if (!data) return []; - for (const item of data) { - const v = item[fieldName]; + const pull = (item: any): string | null => { + if (item == null) return null; + if (typeof item === "string") return item; + if (typeof item !== "object") return String(item); + + const df = field.displayField; + if (!df) { debugger; return null; } + + if (Array.isArray(df)) { + const parts = df.map((k) => item[k]).filter((v) => v != null); + if (parts.length > 0) return parts.join(" "); + } else { + const v = item[df]; + if (v != null) return String(v); + } + + debugger; + return null; + }; + + for (const row of data) { + const v = row[fieldName]; if (v == null) continue; - if (field.type === "array" && Array.isArray(v)) { + if (Array.isArray(v)) { for (const el of v) { - if (el != null && typeof el === "object") { - const d = getDisplayValue(el, field); - if (d) values.add(d); - } else if (el != null) { - values.add(String(el)); - } + const label = pull(el); + if (label) values.add(label); } - } else if (typeof v === "object") { - const d = getDisplayValue(v, field); - if (d) values.add(d); } else { - values.add(String(v)); + const label = pull(v); + if (label) values.add(label); } } + console.log('extracted', fieldName, Array.from(values).sort()) return Array.from(values).sort(); } @@ -68,49 +70,17 @@ function renderFilterInput( value: any, onChange: (key: string, val: any) => void ) { - const isRange = - field.type === "number" || field.type === "datetime" || field.type === "date"; - - if (isRange) { - const rangeVal = (value as { min?: string; max?: string; start?: string; end?: string }) || {}; - const isDate = field.type === "datetime" || field.type === "date"; - const inputType = isDate ? "datetime-local" : "number"; - - if (isDate) { - return ( - - - {field.label} - - onChange("start", e.target.value || undefined)} - InputLabelProps={{ shrink: true }} - sx={{ width: 190 }} - /> - onChange("end", e.target.value || undefined)} - InputLabelProps={{ shrink: true }} - sx={{ width: 190 }} - /> - - ); - } + const filterType = field.filterType; + if (filterType === "number-range") { + const rangeVal = (value as { min?: string; max?: string }) || {}; return ( {field.label} - {field.label} - - + + + {field.label} + + onChange("start", e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + sx={{ width: 190 }} + /> + onChange("end", e.target.value || undefined)} + InputLabelProps={{ shrink: true }} + sx={{ width: 190 }} + /> + ); } - if (options.length <= 20) { + if (filterType === "multiselect") { + const selected = Array.isArray(value) ? value : []; return ( onChange("value", val || undefined)} + value={selected} + onChange={(_, val) => onChange("value", val.length > 0 ? val : undefined)} renderInput={(params) => ( )} - sx={{ minWidth: 180 }} + sx={{ minWidth: 220 }} size="small" /> ); } return ( - onChange("value", e.target.value || undefined)} - size="small" + options={options} + value={value ?? null} + onChange={(_, val) => onChange("value", val || undefined)} + renderInput={(params) => ( + + )} sx={{ minWidth: 180 }} + size="small" /> ); } diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx index 4c69014..b239be4 100644 --- a/react-openapi/components/ResourceView.tsx +++ b/react-openapi/components/ResourceView.tsx @@ -16,7 +16,7 @@ interface ResourceViewProps { import { GridPaginationModel } from '@mui/x-data-grid'; function getFilterDisplayFields(field: ResourceField): string[] { - if (!field.displayField) return ["name", "title", "label"]; + if (!field.displayField) return []; return (Array.isArray(field.displayField) ? field.displayField : [field.displayField]).filter( (df): df is string => !!df ); @@ -57,20 +57,32 @@ function applyClientFilters( return true; } + if (Array.isArray(filterValue)) { + if (itemValue && typeof itemValue === "object") { + const dispFields = getFilterDisplayFields(field); + const itemDisplay = dispFields.map((df) => itemValue[df]).filter((v) => v != null).join(" "); + return filterValue.includes(itemDisplay); + } + return filterValue.includes(String(itemValue)); + } + if (!filterValue) return true; if (field.type === "boolean") { return String(itemValue) === filterValue; } - if (field.type === "array" && Array.isArray(itemValue) && field.relation) { - const dispFields = getFilterDisplayFields(field); - return itemValue.some((el: any) => - dispFields.some((df) => String(el[df]) === String(filterValue)) - ); + if (field.type === "array" && Array.isArray(itemValue)) { + return itemValue.some((el: any) => { + if (el != null && typeof el === "object") { + const dispFields = getFilterDisplayFields(field); + return dispFields.some((df) => String(el[df]) === String(filterValue)); + } + return String(el) === String(filterValue); + }); } - if (field.relation && itemValue && typeof itemValue === "object") { + if (itemValue && typeof itemValue === "object") { const dispFields = getFilterDisplayFields(field); return dispFields.some((df) => String(itemValue[df]) === String(filterValue)); } diff --git a/react-openapi/types/config.ts b/react-openapi/types/config.ts index 25ef31a..a0cba15 100644 --- a/react-openapi/types/config.ts +++ b/react-openapi/types/config.ts @@ -20,6 +20,7 @@ export interface ResourceField { displayField?: string | string[]; formatter?: (value: any) => string; relation?: string; // Name of the target resource + filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; } export type ResourceMode = "server" | "client"; diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts index bb11784..56c6a8a 100644 --- a/react-openapi/types/overrides.ts +++ b/react-openapi/types/overrides.ts @@ -7,6 +7,7 @@ export interface FieldOverride { displayField?: string | string[]; display?: boolean; formatter?: (value: any) => string; + filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; } export interface ResourceOverride { diff --git a/src/openapi-config.ts b/src/openapi-config.ts index d837480..3269912 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -7,6 +7,7 @@ export const configuration: Record = { fields: { payee: { displayField: "name", + filterType: "autocomplete", }, payor: { display: false, @@ -14,11 +15,14 @@ export const configuration: Record = { }, account: { displayField: "name", + filterType: "multiselect", }, tags: { displayField: ["name", "icon"], + filterType: "autocomplete", }, occurred_at: { + filterType: "date-range", formatter: (val: string) => { const date = new Date(val); const day = date.getDate(); @@ -36,6 +40,9 @@ export const configuration: Record = { return `${day}${suffix(day)} ${month} ${year}`; } }, + amount: { + filterType: "number-range", + }, created_at: { display: false } -- 2.49.1 From 653c8caecfee01ddd88ee9a3c321645ae1651595 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 01:34:52 +0530 Subject: [PATCH 06/20] fixes --- react-openapi/components/EnhancedTable.tsx | 2 +- react-openapi/components/ResourceView.tsx | 8 ++++---- react-openapi/types/config.ts | 6 ++++-- react-openapi/types/overrides.ts | 6 ++++-- react-openapi/utils/openapi_loader.ts | 8 ++++++-- src/openapi-config.ts | 6 ++++-- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/react-openapi/components/EnhancedTable.tsx b/react-openapi/components/EnhancedTable.tsx index 434a4cc..55b6286 100644 --- a/react-openapi/components/EnhancedTable.tsx +++ b/react-openapi/components/EnhancedTable.tsx @@ -61,7 +61,7 @@ export default function EnhancedTable({ const isMobile = useMediaQuery(theme.breakpoints.down('md')); const navigate = useNavigate(); - const isServer = config.mode !== "client"; + const isServer = config.filterOptions?.mode !== "client"; const [internalPaginationModel, setInternalPaginationModel] = React.useState({ page: 0, pageSize: 10, diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx index b239be4..a70e628 100644 --- a/react-openapi/components/ResourceView.tsx +++ b/react-openapi/components/ResourceView.tsx @@ -102,7 +102,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV const isView = !!id && !isEdit; const isList = !id && !isCreate; - const isServer = config.mode !== "client"; + const isServer = config.filterOptions?.mode !== "client"; const [paginationModel, setPaginationModel] = React.useState({ page: 0, @@ -170,11 +170,11 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV {isList ? ( - {!isServer && config.filterableFields && config.filterableFields.length > 0 && ( + {!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && ( ; pagination?: boolean; hidden?: boolean; - mode: ResourceMode; - filterableFields?: string[]; + filterOptions?: { + mode?: ResourceMode; + fields?: string[]; + }; } export interface AppConfig { diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts index 56c6a8a..6200308 100644 --- a/react-openapi/types/overrides.ts +++ b/react-openapi/types/overrides.ts @@ -14,6 +14,8 @@ export interface ResourceOverride { fields?: Record; pagination?: boolean; hidden?: boolean; - mode?: "server" | "client"; - filterableFields?: string[]; + filterOptions?: { + mode?: "server" | "client"; + fields?: string[]; + }; } diff --git a/react-openapi/utils/openapi_loader.ts b/react-openapi/utils/openapi_loader.ts index cd91a1d..ac472f4 100644 --- a/react-openapi/utils/openapi_loader.ts +++ b/react-openapi/utils/openapi_loader.ts @@ -154,6 +154,8 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco const resourceOverride = configuration[name] || {}; + const fo = resourceOverride.filterOptions || {}; + resources.push({ name, label: schema.title || label, @@ -163,8 +165,10 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco fields, pagination: resourceOverride.pagination, hidden: resourceOverride.hidden, - mode: resourceOverride.mode || "server", - filterableFields: resourceOverride.filterableFields, + filterOptions: { + mode: fo.mode || "server", + fields: fo.fields, + }, }); } diff --git a/src/openapi-config.ts b/src/openapi-config.ts index 3269912..3227a37 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -2,8 +2,10 @@ import { ResourceOverride } from "../react-openapi/types/overrides"; export const configuration: Record = { expenses: { - mode: "client", - filterableFields: ["payee", "account", "tags", "occurred_at", "amount"], + filterOptions: { + mode: "client", + fields: ["payee", "account", "tags", "occurred_at", "amount"], + }, fields: { payee: { displayField: "name", -- 2.49.1 From 170769c317551dd1d2b6a743d0f4c7f1d526d2e2 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 01:36:08 +0530 Subject: [PATCH 07/20] fixes --- src/openapi-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openapi-config.ts b/src/openapi-config.ts index 3227a37..638c9c4 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -4,7 +4,7 @@ export const configuration: Record = { expenses: { filterOptions: { mode: "client", - fields: ["payee", "account", "tags", "occurred_at", "amount"], + fields: ["account", "payee", "tags", "occurred_at", "amount"], }, fields: { payee: { -- 2.49.1 From 4a3428ed8f4ad8683d167ce4bcc9dac631613547 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 13:52:34 +0530 Subject: [PATCH 08/20] fixes for mobile view broken --- react-openapi/components/EnhancedTable.tsx | 24 ++++++++++++++++++++-- react-openapi/components/FilterBar.tsx | 9 +++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/react-openapi/components/EnhancedTable.tsx b/react-openapi/components/EnhancedTable.tsx index 55b6286..10dc971 100644 --- a/react-openapi/components/EnhancedTable.tsx +++ b/react-openapi/components/EnhancedTable.tsx @@ -130,6 +130,15 @@ export default function EnhancedTable({ return cols; }, [config, onDelete, navigate, onNavigateToResource]); + const mobilePageSize = 10; + const [mobilePage, setMobilePage] = React.useState(0); + const mobileTotalPages = Math.ceil(data.length / mobilePageSize) || 1; + const mobileData = data.slice(mobilePage * mobilePageSize, (mobilePage + 1) * mobilePageSize); + + React.useEffect(() => { + if (mobilePage >= mobileTotalPages) setMobilePage(0); + }, [data.length, mobilePage, mobileTotalPages]); + if (isMobile) { return ( @@ -140,7 +149,7 @@ export default function EnhancedTable({ - {data.map((row) => ( + {mobileData.map((row) => ( ))} + + + + Page {mobilePage + 1} of {mobileTotalPages} + + + ); } @@ -240,7 +260,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) { {field.label} - + diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index d9f226d..8455741 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -5,10 +5,6 @@ import { Paper, TextField, Autocomplete, - Select, - MenuItem, - FormControl, - InputLabel, Typography, } from "@mui/material"; import FilterListIcon from "@mui/icons-material/FilterList"; @@ -59,7 +55,7 @@ function extractOptions( } } - console.log('extracted', fieldName, Array.from(values).sort()) + // console.log('extracted', fieldName, Array.from(values).sort()) return Array.from(values).sort(); } @@ -245,7 +241,8 @@ export default function FilterBar({ const field = fields[fieldName]; if (!field) return null; - const options = extractOptions(fieldName, field, data ?? []); + const needsOptions = !field.filterType || field.filterType === "autocomplete" || field.filterType === "multiselect"; + const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : []; const raw = draft[fieldName]; return ( -- 2.49.1 From 6803fb6b56df1725772370ac61de0ddeb0e792a9 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 13:58:00 +0530 Subject: [PATCH 09/20] multi auto complete --- react-openapi/components/FilterBar.tsx | 9 ++++++--- react-openapi/components/ResourceView.tsx | 11 ++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index 8455741..7bed711 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -142,16 +142,19 @@ function renderFilterInput( ); } + const selected = Array.isArray(value) ? value : []; return ( onChange("value", val || undefined)} + value={selected} + onChange={(_, val) => onChange("value", val.length > 0 ? val : undefined)} + ChipProps={{ size: 'small' }} renderInput={(params) => ( )} - sx={{ minWidth: 180 }} + sx={{ minWidth: 220 }} size="small" /> ); diff --git a/react-openapi/components/ResourceView.tsx b/react-openapi/components/ResourceView.tsx index a70e628..d75b439 100644 --- a/react-openapi/components/ResourceView.tsx +++ b/react-openapi/components/ResourceView.tsx @@ -28,7 +28,7 @@ function applyClientFilters( fields: Record ): any[] { const entries = Object.entries(filters).filter(([_, v]) => { - if (v == null || v === "") return false; + if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) return false; if (typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => x == null || x === "")) return false; return true; }); @@ -58,6 +58,15 @@ function applyClientFilters( } if (Array.isArray(filterValue)) { + if (field.type === "array" && Array.isArray(itemValue)) { + return itemValue.some((el: any) => { + if (el != null && typeof el === "object") { + const dispFields = getFilterDisplayFields(field); + return dispFields.some((df) => filterValue.includes(String(el[df]))); + } + return filterValue.includes(String(el)); + }); + } if (itemValue && typeof itemValue === "object") { const dispFields = getFilterDisplayFields(field); const itemDisplay = dispFields.map((df) => itemValue[df]).filter((v) => v != null).join(" "); -- 2.49.1 From 6fc24001a7395cd54481664fe7a829bdce629868 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 14:06:20 +0530 Subject: [PATCH 10/20] Snapshot Selector fixes --- src/Dashboard.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx index 83ce550..70e4a68 100644 --- a/src/Dashboard.tsx +++ b/src/Dashboard.tsx @@ -309,8 +309,7 @@ export default function Dashboard() { getOptionLabel={(o) => o.label} isOptionEqualToValue={(o, v) => o.value === v.value} renderInput={(params) => } - sx={{ '& .MuiOutlinedInput-root': { height: 40, py: 0 } }} - size="small" + sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }} /> -- 2.49.1 From e1b8f4e0c3bdc351c5aed2b74beede4850539dee Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 14:09:41 +0530 Subject: [PATCH 11/20] Admin filter fixes --- react-openapi/components/FilterBar.tsx | 91 ++++++-------------------- 1 file changed, 20 insertions(+), 71 deletions(-) diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index 7bed711..5e1a12d 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -71,26 +71,11 @@ function renderFilterInput( if (filterType === "number-range") { const rangeVal = (value as { min?: string; max?: string }) || {}; return ( - - - {field.label} - - onChange("min", e.target.value || undefined)} - sx={{ width: 120 }} - /> - onChange("max", e.target.value || undefined)} - sx={{ width: 120 }} - /> + + onChange("min", e.target.value || undefined)} sx={{ width: 100 }} /> + onChange("max", e.target.value || undefined)} sx={{ width: 100 }} /> ); } @@ -98,64 +83,25 @@ function renderFilterInput( if (filterType === "date-range") { const rangeVal = (value as { start?: string; end?: string }) || {}; return ( - - - {field.label} - - onChange("start", e.target.value || undefined)} - InputLabelProps={{ shrink: true }} - sx={{ width: 190 }} - /> - onChange("end", e.target.value || undefined)} - InputLabelProps={{ shrink: true }} - sx={{ width: 190 }} - /> + + onChange("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} /> + onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} /> ); } - if (filterType === "multiselect") { - const selected = Array.isArray(value) ? value : []; - return ( - onChange("value", val.length > 0 ? val : undefined)} - renderInput={(params) => ( - - )} - sx={{ minWidth: 220 }} - size="small" - /> - ); - } - const selected = Array.isArray(value) ? value : []; return ( onChange("value", val.length > 0 ? val : undefined)} - ChipProps={{ size: 'small' }} - renderInput={(params) => ( - - )} - sx={{ minWidth: 220 }} - size="small" + renderInput={(params) => } + sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }} /> ); } @@ -249,20 +195,23 @@ export default function FilterBar({ const raw = draft[fieldName]; return ( - + + + {field.label} + {renderFilterInput(fieldName, field, options, raw, (key, val) => updateDraft(fieldName, key, val) )} - + ); })} - - -- 2.49.1 From a8f5789c03984f18a74728de680a7d39b188672b Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 14:21:07 +0530 Subject: [PATCH 12/20] smarter multiselect --- react-openapi/components/FilterBar.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index 5e1a12d..447a166 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -7,6 +7,7 @@ import { Autocomplete, Typography, } from "@mui/material"; +import DoneIcon from "@mui/icons-material/Done"; import FilterListIcon from "@mui/icons-material/FilterList"; import { ResourceField, ResourceMode } from "../types/config"; @@ -93,15 +94,35 @@ function renderFilterInput( } const selected = Array.isArray(value) ? value : []; + // const sortedOptions = React.useMemo(() => { + // const sel = new Set(selected); + // const picked: string[] = []; + // const rest: string[] = []; + // for (const o of options) { + // if (sel.has(o)) picked.push(o); + // else rest.push(o); + // } + // return [...picked, ...rest]; + // }, [options, selected]); + return ( onChange("value", val.length > 0 ? val : undefined)} + renderOption={(props, option, { selected }) => ( +
  • + {selected ? : } + {option} +
  • + )} renderInput={(params) => } - sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }} + sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }} /> ); } -- 2.49.1 From fa32ab17dee6185cad8795651a22f65a7963b0ea Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 14:35:39 +0530 Subject: [PATCH 13/20] sorted options only when selecting again --- react-openapi/components/FilterBar.tsx | 87 ++++++++++++++++++-------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index 447a166..4e28b7e 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -11,6 +11,63 @@ import DoneIcon from "@mui/icons-material/Done"; import FilterListIcon from "@mui/icons-material/FilterList"; import { ResourceField, ResourceMode } from "../types/config"; +function FilterAutocomplete({ + options, + value, + label, + onChange, +}: { + options: string[]; + value: string[]; + label: string; + onChange: (val: string[]) => void; +}) { + const listboxRef = React.useRef(null); + const scrollPosRef = React.useRef(0); + const [frozenValue, setFrozenValue] = React.useState(value); + + const sortedOptions = React.useMemo(() => { + const sel = new Set(frozenValue); + const picked: string[] = []; + const rest: string[] = []; + for (const o of options) { + if (sel.has(o)) picked.push(o); + else rest.push(o); + } + return [...picked, ...rest]; + }, [options, frozenValue]); + + return ( + option} + onChange={(_, val) => onChange(val.length > 0 ? val : [])} + onOpen={() => setFrozenValue(value)} + onClose={() => setFrozenValue(value)} + ListboxProps={{ + ref: listboxRef, + onScroll: (e) => { scrollPosRef.current = (e.target as HTMLUListElement).scrollTop; }, + }} + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + return ( +
  • + {selected ? : } + {option} +
  • + ); + }} + renderInput={(params) => } + sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }} + /> + ); +} + function extractOptions( fieldName: string, field: ResourceField, @@ -94,35 +151,13 @@ function renderFilterInput( } const selected = Array.isArray(value) ? value : []; - // const sortedOptions = React.useMemo(() => { - // const sel = new Set(selected); - // const picked: string[] = []; - // const rest: string[] = []; - // for (const o of options) { - // if (sel.has(o)) picked.push(o); - // else rest.push(o); - // } - // return [...picked, ...rest]; - // }, [options, selected]); return ( - onChange("value", val.length > 0 ? val : undefined)} - renderOption={(props, option, { selected }) => ( -
  • - {selected ? : } - {option} -
  • - )} - renderInput={(params) => } - sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }} + value={selected} + label={field.label} + onChange={(val) => onChange("value", val.length > 0 ? val : undefined)} /> ); } -- 2.49.1 From 6fe70496c0f255773181c50ce4ee74c33825c321 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 14:39:35 +0530 Subject: [PATCH 14/20] clicking on ships open dropdown not expand chips --- react-openapi/components/FilterBar.tsx | 34 +++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index 4e28b7e..a5d3a23 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { Box, Button, + Chip, Paper, TextField, Autocomplete, @@ -24,6 +25,7 @@ function FilterAutocomplete({ }) { const listboxRef = React.useRef(null); const scrollPosRef = React.useRef(0); + const [open, setOpen] = React.useState(false); const [frozenValue, setFrozenValue] = React.useState(value); const sortedOptions = React.useMemo(() => { @@ -42,13 +44,13 @@ function FilterAutocomplete({ multiple freeSolo disableCloseOnSelect - limitTags={1} + open={open} + onOpen={() => { setOpen(true); setFrozenValue(value); }} + onClose={() => { setOpen(false); setFrozenValue(value); }} options={sortedOptions} value={value} getOptionKey={(option) => option} onChange={(_, val) => onChange(val.length > 0 ? val : [])} - onOpen={() => setFrozenValue(value)} - onClose={() => setFrozenValue(value)} ListboxProps={{ ref: listboxRef, onScroll: (e) => { scrollPosRef.current = (e.target as HTMLUListElement).scrollTop; }, @@ -62,6 +64,32 @@ function FilterAutocomplete({ ); }} + renderTags={(tagValue, getTagProps) => { + const maxChips = 1; + return ( + <> + {tagValue.slice(0, maxChips).map((tag, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return setOpen(true)} + sx={{ cursor: 'pointer' }} + />; + })} + {tagValue.length > maxChips && ( + setOpen(true)} + sx={{ cursor: 'pointer' }} + /> + )} + + ); + }} renderInput={(params) => } sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }} /> -- 2.49.1 From d767cf0a237beeeddec70e7d6fc1dc9e90dc0ebb Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 14:48:23 +0530 Subject: [PATCH 15/20] toggleDropdown for chip to open and close, limiting tag chars --- react-openapi/components/FilterBar.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index a5d3a23..7effd01 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -28,6 +28,13 @@ function FilterAutocomplete({ const [open, setOpen] = React.useState(false); const [frozenValue, setFrozenValue] = React.useState(value); + const toggleDropdown = () => { + setOpen(prev => { + const next = !prev; + setFrozenValue(value); + return next; + }); + }; const sortedOptions = React.useMemo(() => { const sel = new Set(frozenValue); const picked: string[] = []; @@ -45,8 +52,8 @@ function FilterAutocomplete({ freeSolo disableCloseOnSelect open={open} - onOpen={() => { setOpen(true); setFrozenValue(value); }} - onClose={() => { setOpen(false); setFrozenValue(value); }} + onOpen={toggleDropdown} + onClose={toggleDropdown} options={sortedOptions} value={value} getOptionKey={(option) => option} @@ -73,9 +80,9 @@ function FilterAutocomplete({ return 10 ? `${tag.slice(0, 8)}..` : tag} size="small" - onClick={() => setOpen(true)} + onClick={toggleDropdown} sx={{ cursor: 'pointer' }} />; })} @@ -83,7 +90,7 @@ function FilterAutocomplete({ setOpen(true)} + onClick={toggleDropdown} sx={{ cursor: 'pointer' }} /> )} -- 2.49.1 From 7de89142835f1399e685eb87dd20b4084be529f6 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 14:51:59 +0530 Subject: [PATCH 16/20] mobile view 1 filter per line fix --- react-openapi/components/FilterBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react-openapi/components/FilterBar.tsx b/react-openapi/components/FilterBar.tsx index 7effd01..a528a85 100644 --- a/react-openapi/components/FilterBar.tsx +++ b/react-openapi/components/FilterBar.tsx @@ -286,7 +286,7 @@ export default function FilterBar({ const raw = draft[fieldName]; return ( - + {field.label} -- 2.49.1 From 44c42892d852bb37feaf3e92d714c7094093162f Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 15:57:32 +0530 Subject: [PATCH 17/20] copy ids --- src/FetchRequests.tsx | 17 +++++++++++++++-- src/ReportSnapshots.tsx | 26 ++++++++++++++------------ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/FetchRequests.tsx b/src/FetchRequests.tsx index 6287f1d..9a21e6a 100644 --- a/src/FetchRequests.tsx +++ b/src/FetchRequests.tsx @@ -28,6 +28,7 @@ import { 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 { useFetchRequestsList, useCreateFetchRequest, @@ -256,7 +257,7 @@ export default function FetchRequests() { - ID + Fingerprint Source Account Status @@ -268,7 +269,19 @@ export default function FetchRequests() { {requests.map((req: FetchRequest) => ( - {req.id.slice(0, 8)}... + + {req.fingerprint} + { + navigator.clipboard.writeText(req.fingerprint); + setSnackbar({ message: "Copied!", severity: "success" }); + }} + sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }} + > + + + {"path" in req.source ? "File" : "Email"} diff --git a/src/ReportSnapshots.tsx b/src/ReportSnapshots.tsx index e70bb2f..bd47712 100644 --- a/src/ReportSnapshots.tsx +++ b/src/ReportSnapshots.tsx @@ -28,6 +28,7 @@ import { import DeleteIcon from "@mui/icons-material/Delete"; import AddCircleIcon from "@mui/icons-material/AddCircle"; import RefreshIcon from "@mui/icons-material/Refresh"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import { useReportSnapshotsList, useCreateSnapshot, @@ -41,7 +42,6 @@ function formatDate(iso: string) { } 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(""); @@ -60,7 +60,6 @@ export default function ReportSnapshots() { 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, @@ -81,7 +80,6 @@ export default function ReportSnapshots() { }; const resetForm = () => { - setAccounts(""); setIgnoreSelf(false); setStartDate(""); setEndDate(""); @@ -112,14 +110,6 @@ export default function ReportSnapshots() { - setAccounts(e.target.value)} - size="small" - helperText="Comma-separated account IDs (leave empty for all)" - /> - setIgnoreSelf(e.target.checked)} />} label="Ignore self-transfers" @@ -215,7 +205,19 @@ export default function ReportSnapshots() { {snapshots.map((snap: ReportSnapshot) => ( - {snap.snapshot_id.slice(0, 12)}... + + {snap.snapshot_id} + { + navigator.clipboard.writeText(snap.snapshot_id); + setSnackbar({ message: "Copied!", severity: "success" }); + }} + sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }} + > + + + {formatDate(snap.created_at)} -- 2.49.1 From 8f57bd1745d92f02234c0db6e81020ef21e3ecd1 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 15:58:14 +0530 Subject: [PATCH 18/20] ignoreSelf default true --- src/ReportSnapshots.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ReportSnapshots.tsx b/src/ReportSnapshots.tsx index bd47712..be20049 100644 --- a/src/ReportSnapshots.tsx +++ b/src/ReportSnapshots.tsx @@ -42,7 +42,7 @@ function formatDate(iso: string) { } export default function ReportSnapshots() { - const [ignoreSelf, setIgnoreSelf] = React.useState(false); + const [ignoreSelf, setIgnoreSelf] = React.useState(true); const [startDate, setStartDate] = React.useState(""); const [endDate, setEndDate] = React.useState(""); const [minAmount, setMinAmount] = React.useState(""); -- 2.49.1 From d8c6c3cdb3dd85f6916723e1895e6f24ad4ecfcb Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 16:02:36 +0530 Subject: [PATCH 19/20] added favicon --- index.html | 1 + public/favicon.png | Bin 0 -> 2910 bytes 2 files changed, 1 insertion(+) create mode 100644 public/favicon.png diff --git a/index.html b/index.html index 264cd82..72d2f5a 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,7 @@ rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" /> + khata - Aetoskia diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..a3ca36ddeabfba9f69a8baf1fcf79bb86ffcf9c2 GIT binary patch literal 2910 zcmaJ@hffm@6J?c6sX!TmKp8S+3KWo4KsLyhC1oSZ7Gxu37DdXGW!XcKDN6)t!J_zM z%aT1sMlG_3Eg${^-zD$f<#Ng8E|=Ux-ZRx>qQ6d0Mn=YDps!YbPZgV#2&5SJm|2Z9BI;Zs2pkE$c|yc%{qe=2Io=l@NCR7Lsu z&md257v;QqUL)K!(}OU{R}ts8OV!?Q4qBH)Z*6@3nBJp$y%34Uo^t(Bb%7BmB-> zyKTb*A?oeb`+63nQ|jFxA6W%r^TanD4DEMsRBlLGYTbDyL9+Ey41|_7*zv5~QPtaG z?UD)#%H|=&pM0;5{zYhQHiBc@qrbpud*o!P_aucsZVow*mfXHq43wDFchgo_|LLiH z*uwb%u+(PfN{H~J)f0XEh@sF|EEC%CbM?fsy!yY9B4yM1om++3r2diOB@F8fe*0jV z*@NM(?95+^n}uebB5c8Eyw|Q}WFbhGTPvDdsgA}$b=p&eBYnQ~F`7GORTdrV;P68v zRFp`(cfu1EHFwv;SVo;uQKJ~ketLABi!m3DbEbDl^tzd}>y{rY-`E`%mPW;mln7%! ziT|S|mx4ql3LE8OUQX1Zien27*M>}57l2=ji{C;#7VMV;bk|waFA%gQB;FAfM)swY z$<_E?_@AuEN{{6s6Xjgg>I!o@MYU$?v^VW*&}?a(FF?LwdU7M+GKOowzWIGRrhY*c zzBOJ6V&|o+|J{cf>@7f5cKyUXPqxpRu)XzOqC3m|8fsOcN%Fj?U zdt$&7w37e4bbrgK8&K6$di}wtunH9*40T!(=#jJrF-R(q>OX<| zjuQ%a+*6J>icxNWVs)E0!;b`X9JnSDL_4`SO^^VR>Gn!_DEi;dk}g_uWo5#>3&)rY zVHIdWk`3Oeb-O3p@ayi6z_-gSw|W>2VYe&0e<2541N;lRv>tMgny*u9;&vvcJ+Aek1<^1q|=0h=R)sVa3qg zS!f@^pHf}W{-c~>3cJNY6{3Zm=Q(^LJTx?bxwTb`$)d^yd{ZCho#Ke$} zvD=5ToHt}1q_Gtd_)rO)maVp9IZz+=qH?zO7Xoyr{|r>r z*c)8sXkL9FNX!Mf)f|hr2vGr!B%-CHdz7Jc=7&M8?Wzgy96!Lo`i|}s zo7We?5P7d*8vu*#A4}N!LRS4DI|oOmfykw7oKM`e4sEWbj;mhKFwXaW`;i4~-Dw9W zTx-a;w_tTYuQmg;D-e(9v+BWd;m8Y*S7|wS?m~4_rH9tn-J8cL^>8ENleU`Mg`nL~ zp4V!V{hY(~OPK}vOEm;*F)68ldok&4p{+I@ODqUC#;Eh(i(-&9pyTz_gUQn;rZ4i0 z5yGHZZOqJ{#())pOzW*IP&UU1AF4w0dren2Tmq|7AQf=|fXsfqcp8iP#QS|QWXE#i zlQv7nC>m6j-OJ!LusmY2&Gb(Cd8zp>&%DcQ3l}O+?^)${F8}Y_Di4E$H$;>~1?5U3n4^kO`Zt{gMreru&|9cr|}r@NqUs@a?fU zqYyC{wOD^FE(+v^+a)W8bW}%e58$}&+!wVlPquUM;Wt3-oB<1-|JF03kzIdmP zZ7_>*xjbH0%DA<72tPk4Df|MFT+Pg-X+H;D18QPq!I>U9w$B;T(m}gBO`07pL~+qi znG75;YUJUY&C7=a#!z%CpBytG@x=?PnYIv0NoO?e!Ox*YzA7DT2Zn#%gk~^Yk-zC_ zaw9Tho#2_5s1^~Xy(3wx>csBppP$dw`1l74Eq!KO-Dkj{qU89n2hQ^pBj)|`T?1T z@R^Fi03kd;V3Ysxd`A)rEWjYR5#d;(7Hq>a(5!Ot>akZsR}_hCd^{9pHDR^?WKE5d zGCl=8RA`}(3Tfr`7%Kx*048I@9)`j8=w7D1W@;V{5bZ9Q7X0$5>=utMbw7mmH@2pCWRTCM&8v6TUUtl(TRE&OAg-f2&ii*nZ}#9-{Rzh0)UM^Q|84i_7MPcD|+lPjk zqiJf-a&M!IkOesu<0Q2w&wqrgN=PEkO!3L(pgRNn-3(wc=?O2&-j4A_YmjV5U0v^` zIl;ZA_1`BuGnLI2>U_DIjjEIqWHh4aPZs{VlauRW43`6-jTL(UuYpa0@jOfLFclZ^ zhq9;ncy31Q)VCpz(_5#J+H-f7vFm$LT#}@VVf4LywJfFa(Kg`qfGag~m1ChUjLjcl upNN8#QKxfBVq-gJ&k7L#&w49@|MHdlG!lGhR+Fy;Gns*|sZKo%j`$xR^M08C literal 0 HcmV?d00001 -- 2.49.1 From c64b0ed91f153f5376622543c78f811369d5c22d Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 22:47:13 +0530 Subject: [PATCH 20/20] added favicon --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 72d2f5a..256428e 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" /> - + khata - Aetoskia -- 2.49.1