## 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: #7
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
340 lines
8.8 KiB
TypeScript
340 lines
8.8 KiB
TypeScript
import * as React from "react";
|
|
import {
|
|
Box,
|
|
Container,
|
|
CircularProgress,
|
|
Alert,
|
|
TextField,
|
|
Paper,
|
|
Autocomplete,
|
|
Button
|
|
} from "@mui/material";
|
|
|
|
import DashboardView from "./components/Dashboard";
|
|
|
|
import {
|
|
DashboardState,
|
|
DashboardStateSetters,
|
|
DashboardFlow,
|
|
} from "./components/Dashboard";
|
|
|
|
import { configuration } from "./dashboard-config";
|
|
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<DashboardState>({
|
|
flow: "outflows",
|
|
periodType: "rolling",
|
|
selectedPeriodId: null,
|
|
selectedGroupKey: null,
|
|
comparison: false,
|
|
});
|
|
|
|
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]);
|
|
const [appliedTags, setAppliedTags] = React.useState<string[]>([]);
|
|
|
|
const [payeeInput, setPayeeInput] = React.useState<string[]>([]);
|
|
const [tagsInput, setTagsInput] = React.useState<string[]>([]);
|
|
|
|
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
|
|
const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
|
|
|
|
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null);
|
|
|
|
const { data: snapshotsData } = useResourceByName("reports").useList();
|
|
const snapshotOptions = React.useMemo(() => {
|
|
const options: { label: string; value: string | null }[] = [
|
|
{ label: "Latest (auto)", value: null },
|
|
];
|
|
if (snapshotsData?.data) {
|
|
for (const snap of snapshotsData.data) {
|
|
options.push({
|
|
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
|
|
value: snap.snapshot_id,
|
|
});
|
|
}
|
|
}
|
|
return options;
|
|
}, [snapshotsData]);
|
|
|
|
const selectedSnapshotOption = snapshotOptions.find((o) => o.value === selectedSnapshotId) ?? snapshotOptions[0];
|
|
|
|
const report = useReport({
|
|
snapshot_id: selectedSnapshotId ?? undefined,
|
|
periods: ["daily", "weekly", "monthly", "all"],
|
|
flow: state.flow,
|
|
payee: appliedPayees.length > 0 ? appliedPayees : undefined,
|
|
tags: appliedTags.length > 0 ? appliedTags : undefined,
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (report.data) {
|
|
setLoadedPayees(prev => {
|
|
const pSet = new Set<string>(prev);
|
|
report.data.buckets.forEach((b: any) => {
|
|
Object.values(b.periods).forEach((periodArray: any) => {
|
|
periodArray?.forEach((p: any) => {
|
|
p.metric?.transactions?.forEach((t: any) => {
|
|
if (t.payee?.name) pSet.add(t.payee.name);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
return Array.from(pSet).sort();
|
|
});
|
|
|
|
setLoadedTags(prev => {
|
|
const tSet = new Set<string>(prev);
|
|
report.data.buckets.forEach((b: any) => {
|
|
Object.values(b.periods).forEach((periodArray: any) => {
|
|
periodArray?.forEach((p: any) => {
|
|
p.metric?.transactions?.forEach((t: any) => {
|
|
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
|
|
});
|
|
});
|
|
});
|
|
});
|
|
return Array.from(tSet).sort();
|
|
});
|
|
}
|
|
}, [report.data]);
|
|
|
|
const toggleFlow =
|
|
React.useCallback(() => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
|
|
flow:
|
|
prev.flow ===
|
|
"outflows"
|
|
? "inflows"
|
|
: "outflows",
|
|
|
|
selectedGroupKey:
|
|
null,
|
|
|
|
selectedPeriodId:
|
|
null,
|
|
}));
|
|
}, []);
|
|
|
|
const setFlow =
|
|
React.useCallback(
|
|
(
|
|
flow: DashboardFlow
|
|
) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
|
|
flow,
|
|
|
|
selectedGroupKey:
|
|
null,
|
|
|
|
selectedPeriodId:
|
|
null,
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const togglePeriodType =
|
|
React.useCallback(() => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
|
|
periodType:
|
|
prev.periodType ===
|
|
"rolling"
|
|
? "calendar"
|
|
: "rolling",
|
|
}));
|
|
}, []);
|
|
|
|
const toggleComparison =
|
|
React.useCallback(() => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
|
|
comparison:
|
|
!prev.comparison,
|
|
}));
|
|
}, []);
|
|
|
|
const setSelectedPeriodId =
|
|
React.useCallback(
|
|
(
|
|
selectedPeriodId: DashboardState["selectedPeriodId"]
|
|
) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
|
|
selectedPeriodId,
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const setSelectedGroupKey =
|
|
React.useCallback(
|
|
(
|
|
selectedGroupKey: DashboardState["selectedGroupKey"]
|
|
) => {
|
|
setState((prev) => ({
|
|
...prev,
|
|
|
|
selectedGroupKey,
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const stateSetters: DashboardStateSetters =
|
|
React.useMemo(
|
|
() => ({
|
|
toggleFlow,
|
|
|
|
setFlow,
|
|
|
|
togglePeriodType,
|
|
|
|
toggleComparison,
|
|
|
|
setSelectedPeriodId,
|
|
|
|
setSelectedGroupKey,
|
|
}),
|
|
[
|
|
toggleFlow,
|
|
setFlow,
|
|
togglePeriodType,
|
|
toggleComparison,
|
|
setSelectedPeriodId,
|
|
setSelectedGroupKey,
|
|
]
|
|
);
|
|
|
|
const isLoading = report.isLoading;
|
|
const error = report.error;
|
|
|
|
if (isLoading && !report.data) {
|
|
return (
|
|
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Container sx={{ mt: 4 }}>
|
|
<Alert severity="error">{String(error)}</Alert>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
if (!report.data) {
|
|
return null;
|
|
}
|
|
|
|
const data = prepareReport(report.data);
|
|
return (
|
|
<Box>
|
|
<Container>
|
|
<Paper
|
|
sx={{
|
|
mt: 4,
|
|
p: 2,
|
|
display: "flex",
|
|
flexDirection: { xs: "column", sm: "row" },
|
|
gap: 2,
|
|
alignItems: { xs: "stretch", sm: "flex-end" },
|
|
borderRadius: 4,
|
|
mb: -2 // pull up to be closer to the dashboard container below
|
|
}}
|
|
elevation={0}
|
|
variant="outlined"
|
|
>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
|
|
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
|
|
Filter by Payee
|
|
</Box>
|
|
<Autocomplete
|
|
multiple
|
|
freeSolo
|
|
options={loadedPayees}
|
|
value={payeeInput}
|
|
onChange={(_, val) => setPayeeInput(val as string[])}
|
|
renderInput={(params) => <TextField {...params} placeholder="Add payees..." />}
|
|
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
|
|
/>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
|
|
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
|
|
Filter by Tags
|
|
</Box>
|
|
<Autocomplete
|
|
multiple
|
|
freeSolo
|
|
options={loadedTags}
|
|
value={tagsInput}
|
|
onChange={(_, val) => setTagsInput(val as string[])}
|
|
renderInput={(params) => <TextField {...params} placeholder="Add tags..." />}
|
|
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
|
|
/>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', minWidth: { sm: 220 } }}>
|
|
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
|
|
Snapshot
|
|
</Box>
|
|
<Autocomplete
|
|
options={snapshotOptions}
|
|
value={selectedSnapshotOption}
|
|
onChange={(_, option) => setSelectedSnapshotId(option?.value ?? null)}
|
|
getOptionLabel={(o) => o.label}
|
|
isOptionEqualToValue={(o, v) => o.value === v.value}
|
|
renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />}
|
|
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
|
|
/>
|
|
</Box>
|
|
|
|
<Button
|
|
variant="contained"
|
|
size="large"
|
|
onClick={() => {
|
|
setAppliedPayees(payeeInput);
|
|
setAppliedTags(tagsInput);
|
|
}}
|
|
disabled={isLoading}
|
|
sx={{ height: 40, borderRadius: 2 }}
|
|
>
|
|
Apply
|
|
</Button>
|
|
</Paper>
|
|
</Container>
|
|
<DashboardView
|
|
config={configuration}
|
|
data={data}
|
|
state={state}
|
|
stateSetters={stateSetters}
|
|
isFetching={report.isFetching}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|