From d4a79c785d60000cca6baf308d3d35c7730890b3 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sun, 24 May 2026 17:23:02 +0000 Subject: [PATCH] report-fetch-request-ui (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## MR: Fetch Request Pipeline, Report Snapshots, and Admin Filtering ### Summary Adds fetch request pipeline UI, report snapshot manager, snapshot selector on dashboard, and client-side in-memory filtering for the admin panel. Also overhauls the Home page with feature cards and adds navigation links. ### Changes **New Pages** - `/fetch-requests` — Upload bank statements (two-step: upload file, then configure source) or configure email ingestion. Table shows fingerprint (with copy), source type, account, status (color-coded chip), and created date. - `/reports` — Generate cached report snapshots with filters (ignore self, date range, amount range). Table shows snapshot ID (with copy), creation time, and query summary chips. **Dashboard** - Snapshot selector autocomplete dropdown (formatted "Snapshot from {date}"), passes `snapshot_id` to `useReport` - Styled to match other filter controls (caption above, auto-height) **Admin — In-Memory Filtering** - `FilterBar` component: collapsible, Dashboard-style column layout with caption + autocomplete/range/date inputs per filterable field - `FilterAutocomplete` component: multi-select, free solo, checkmark ticks, selected-first sort frozen while dropdown open (prevents scroll reset) - `applyClientFilters` in `ResourceView`: handles number range, datetime range, array (object/string elements), non-relation objects, boolean, primitive exact match - Config-driven via `filterOptions: { mode: "client", fields: [...] }` in `openapi-config.ts` - Mobile view: each filter takes full width (`flex: "0 0 100%"`), no horizontal squeeze - `rowCount` omitted in client pagination mode (suppresses MUI X warning) **Navigation & Home** - Header nav links: Dashboard, Fetch, Reports - Home page redesign: gradient hero, "Import Data" CTA, 4 feature cards (Dashboard, Fetch Requests, Report Snapshots, Admin) with accent-colored hover effects **React-OpenAPI Library** - `filterOptions` (mode + fields) on `ResourceOverride` and `ResourceConfig` types - `EnhancedTable` mobile pagination (10 per page with Prev/Next, prevents browser hang with 10000 records) - `useResource` accepts `filterOptions` from loader **Misc** - `public/favicon.png` added, proper `image/png` type in index.html - 24 files changed, ~1541 insertions, ~100 deletions ### Files Changed (24) | File | Change | |------|--------| | `src/FetchRequests.tsx` | +336 — new page | | `src/ReportSnapshots.tsx` | +273 — new page | | `src/features/fetch-requests/` | +96 — models, hooks, index | | `src/features/report-snapshots/` | +40 — models, hooks, index | | `src/Dashboard.tsx` | +58 — snapshot selector | | `src/Home.tsx` | +224 — redesign with feature cards | | `src/Header.tsx` | +26 — nav links | | `src/main.jsx` | +4 — routes | | `react-openapi/components/FilterBar.tsx` | +313 — new component | | `react-openapi/components/ResourceView.tsx` | +151 — client filtering | | `react-openapi/components/EnhancedTable.tsx` | +62 — mobile pagination | | `react-openapi/types/config.ts` | +7 — filterOptions type | | `react-openapi/types/overrides.ts` | +5 — filterOptions type | | `react-openapi/utils/openapi_loader.ts` | +8 — load filterOptions | | `react-openapi/hooks/useResource.ts` | +6 — filterOptions passthrough | | `react-openapi/index.ts` | +3 — exports | | `src/openapi-config.ts` | +15 — expenses config | | `src/features/report/useReport.ts` | +13 — snapshot_id support | | `index.html` | +1 — favicon link | | `public/favicon.png` | +2910 bytes | Reviewed-on: https://git.aetoskia.com/apps/khata-ui/pulls/7 Co-authored-by: Vishesh 'ironeagle' Bangotra Co-committed-by: Vishesh 'ironeagle' Bangotra --- index.html | 1 + public/favicon.png | Bin 0 -> 2910 bytes react-openapi/components/EnhancedTable.tsx | 62 +++- react-openapi/components/FilterBar.tsx | 313 ++++++++++++++++ react-openapi/components/ResourceView.tsx | 151 ++++++-- react-openapi/hooks/useResource.ts | 6 +- react-openapi/index.ts | 3 +- react-openapi/types/config.ts | 7 + react-openapi/types/overrides.ts | 5 + react-openapi/utils/openapi_loader.ts | 8 +- src/Dashboard.tsx | 58 ++- src/FetchRequests.tsx | 336 ++++++++++++++++++ src/Header.tsx | 26 ++ src/Home.tsx | 226 +++++++++--- src/ReportSnapshots.tsx | 273 ++++++++++++++ .../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/features/report/useReport.ts | 13 +- src/main.jsx | 4 + src/openapi-config.ts | 15 +- 24 files changed, 1542 insertions(+), 101 deletions(-) create mode 100644 public/favicon.png create mode 100644 react-openapi/components/FilterBar.tsx 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/index.html b/index.html index 264cd82..256428e 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 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 literal 0 HcmV?d00001 diff --git a/react-openapi/components/EnhancedTable.tsx b/react-openapi/components/EnhancedTable.tsx index 9e00a1c..10dc971 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.filterOptions?.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]) => { @@ -122,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 ( @@ -132,7 +149,7 @@ export default function EnhancedTable({ - {data.map((row) => ( + {mobileData.map((row) => ( ))} + + + + Page {mobilePage + 1} of {mobileTotalPages} + + + ); } @@ -161,20 +189,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} @@ -234,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 new file mode 100644 index 0000000..a528a85 --- /dev/null +++ b/react-openapi/components/FilterBar.tsx @@ -0,0 +1,313 @@ +import * as React from "react"; +import { + Box, + Button, + Chip, + Paper, + TextField, + 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"; + +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 [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[] = []; + 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 : [])} + ListboxProps={{ + ref: listboxRef, + onScroll: (e) => { scrollPosRef.current = (e.target as HTMLUListElement).scrollTop; }, + }} + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props; + return ( +
  • + {selected ? : } + {option} +
  • + ); + }} + renderTags={(tagValue, getTagProps) => { + const maxChips = 1; + return ( + <> + {tagValue.slice(0, maxChips).map((tag, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return 10 ? `${tag.slice(0, 8)}..` : tag} + size="small" + onClick={toggleDropdown} + sx={{ cursor: 'pointer' }} + />; + })} + {tagValue.length > maxChips && ( + + )} + + ); + }} + renderInput={(params) => } + sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }} + /> + ); +} + +function extractOptions( + fieldName: string, + field: ResourceField, + data: any[] +): string[] { + const values = new Set(); + + if (field.options) return field.options; + if (!data) return []; + + 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 (Array.isArray(v)) { + for (const el of v) { + const label = pull(el); + if (label) values.add(label); + } + } else { + const label = pull(v); + if (label) values.add(label); + } + } + + // console.log('extracted', fieldName, Array.from(values).sort()) + return Array.from(values).sort(); +} + +function renderFilterInput( + fieldName: string, + field: ResourceField, + options: string[], + value: any, + onChange: (key: string, val: any) => void +) { + const filterType = field.filterType; + + if (filterType === "number-range") { + const rangeVal = (value as { min?: string; max?: string }) || {}; + return ( + + onChange("min", e.target.value || undefined)} sx={{ width: 100 }} /> + onChange("max", e.target.value || undefined)} sx={{ width: 100 }} /> + + ); + } + + if (filterType === "date-range") { + const rangeVal = (value as { start?: string; end?: string }) || {}; + return ( + + onChange("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} /> + onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} /> + + ); + } + + const selected = Array.isArray(value) ? value : []; + + return ( + onChange("value", val.length > 0 ? val : undefined)} + /> + ); +} + +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 needsOptions = !field.filterType || field.filterType === "autocomplete" || field.filterType === "multiselect"; + const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : []; + const raw = draft[fieldName]; + + return ( + + + {field.label} + + {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..d75b439 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,132 @@ interface ResourceViewProps { import { GridPaginationModel } from '@mui/x-data-grid'; +function getFilterDisplayFields(field: ResourceField): string[] { + if (!field.displayField) return []; + 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 === "" || (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; + }); + + 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 (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(" "); + 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)) { + 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 (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.filterOptions?.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 +178,31 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV return ( {isList ? ( - navigate(`/admin/${res}/${id}`)} - /> + + {!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && ( + setAppliedFilters({})} + /> + )} + navigate(`/admin/${res}/${id}`)} + /> + ) : ( (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/react-openapi/index.ts b/react-openapi/index.ts index cacffb9..e4012f3 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -1,7 +1,8 @@ export { default as Admin } from "./Admin"; export { api, auth, initializeApiClients } from "./api/client"; export { getAppConfig } from "./config"; -export type { AppConfig, ResourceConfig, ResourceField } from "./types/config"; +export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config"; export { AppProvider } from "./providers/AppProvider"; export { ConfigContext, useConfig } from "./providers/ConfigContext"; export { useResource, useResourceByName } from "./hooks/useResource"; +export { default as FilterBar } from "./components/FilterBar"; diff --git a/react-openapi/types/config.ts b/react-openapi/types/config.ts index 24ee18e..43be512 100644 --- a/react-openapi/types/config.ts +++ b/react-openapi/types/config.ts @@ -20,8 +20,11 @@ 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"; + export interface ResourceConfig { name: string; label: string; @@ -31,6 +34,10 @@ export interface ResourceConfig { fields: Record; pagination?: boolean; hidden?: boolean; + filterOptions?: { + mode?: ResourceMode; + fields?: string[]; + }; } export interface AppConfig { diff --git a/react-openapi/types/overrides.ts b/react-openapi/types/overrides.ts index 6a37d10..6200308 100644 --- a/react-openapi/types/overrides.ts +++ b/react-openapi/types/overrides.ts @@ -7,10 +7,15 @@ export interface FieldOverride { displayField?: string | string[]; display?: boolean; formatter?: (value: any) => string; + filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range"; } export interface ResourceOverride { fields?: Record; pagination?: boolean; hidden?: boolean; + filterOptions?: { + mode?: "server" | "client"; + fields?: string[]; + }; } diff --git a/react-openapi/utils/openapi_loader.ts b/react-openapi/utils/openapi_loader.ts index 112f74c..ac472f4 100644 --- a/react-openapi/utils/openapi_loader.ts +++ b/react-openapi/utils/openapi_loader.ts @@ -154,15 +154,21 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco const resourceOverride = configuration[name] || {}; + const fo = resourceOverride.filterOptions || {}; + resources.push({ name, label: schema.title || label, pluralLabel: pluralLabel, endpoint: listPath, - primaryKey: "id", // Strict default, no heuristics + primaryKey: "id", fields, pagination: resourceOverride.pagination, hidden: resourceOverride.hidden, + filterOptions: { + mode: fo.mode || "server", + fields: fo.fields, + }, }); } diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx index 04a5795..70e4a68 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, @@ -50,10 +83,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 +100,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 +112,7 @@ export default function Dashboard() { return Array.from(tSet).sort(); }); } - }, [report.data?.data]); + }, [report.data]); const toggleFlow = React.useCallback(() => { @@ -219,7 +252,7 @@ export default function Dashboard() { return null; } - const data = prepareReport(report.data.data); + const data = prepareReport(report.data); return ( @@ -265,6 +298,21 @@ 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: 'auto', minHeight: '2.5rem', py: 0.5 } }} + /> + + + + {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 + + ) : ( + + + + + Fingerprint + Source + Account + Status + Created + Actions + + + + {requests.map((req: FetchRequest) => ( + + + + {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"} + + {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/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) => ( + + + + ))} + ); diff --git a/src/ReportSnapshots.tsx b/src/ReportSnapshots.tsx new file mode 100644 index 0000000..be20049 --- /dev/null +++ b/src/ReportSnapshots.tsx @@ -0,0 +1,273 @@ +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 ContentCopyIcon from "@mui/icons-material/ContentCopy"; +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 [ignoreSelf, setIgnoreSelf] = React.useState(true); + 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({ + 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 = () => { + 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 + + + + 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} + { + navigator.clipboard.writeText(snap.snapshot_id); + setSnackbar({ message: "Copied!", severity: "success" }); + }} + sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }} + > + + + + + {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/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, + } + ); } 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..638c9c4 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -2,9 +2,14 @@ import { ResourceOverride } from "../react-openapi/types/overrides"; export const configuration: Record = { expenses: { + filterOptions: { + mode: "client", + fields: ["account", "payee", "tags", "occurred_at", "amount"], + }, fields: { payee: { displayField: "name", + filterType: "autocomplete", }, payor: { display: false, @@ -12,11 +17,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(); @@ -34,15 +42,14 @@ export const configuration: Record = { return `${day}${suffix(day)} ${month} ${year}`; } }, + amount: { + filterType: "number-range", + }, created_at: { display: false } }, - pagination: true, }, - reports: { - hidden: true - } }; export const profileConfiguration = {