## 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>
222 lines
7.6 KiB
TypeScript
222 lines
7.6 KiB
TypeScript
import * as React from 'react';
|
|
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 FilterBar from './FilterBar';
|
|
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
|
|
|
interface ResourceViewProps {
|
|
config: ResourceConfig;
|
|
onNavigateToResource?: (resourceName: string, id: string) => void;
|
|
}
|
|
|
|
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<string, any>,
|
|
fields: Record<string, ResourceField>
|
|
): 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<GridPaginationModel>({
|
|
page: 0,
|
|
pageSize: 10,
|
|
});
|
|
|
|
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
|
|
|
|
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
|
|
|
|
const queryParams = React.useMemo(() => {
|
|
if (!isServer) return { limit: 10000 };
|
|
return {
|
|
skip: paginationModel.page * paginationModel.pageSize,
|
|
limit: paginationModel.pageSize,
|
|
};
|
|
}, [isServer, paginationModel]);
|
|
|
|
const listQuery = useList(queryParams);
|
|
const itemQuery = useRead(id || "");
|
|
|
|
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();
|
|
|
|
const handleEdit = (item: any) => {
|
|
navigate(`/admin/${config.name}/edit/${item[config.primaryKey]}`);
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
navigate(`/admin/${config.name}/create`);
|
|
};
|
|
|
|
const handleSave = async (formData: any) => {
|
|
try {
|
|
if (isEdit) {
|
|
await updateMutation.mutateAsync({ id: id!, data: formData });
|
|
} else {
|
|
await createMutation.mutateAsync(formData);
|
|
}
|
|
navigate(`/admin/${config.name}`);
|
|
} catch (err) {
|
|
console.error('Save failed:', err);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (itemId: string) => {
|
|
if (window.confirm('Are you sure you want to delete this item?')) {
|
|
await deleteMutation.mutateAsync(itemId);
|
|
}
|
|
};
|
|
|
|
if (isList && listQuery.isLoading) return <CircularProgress />;
|
|
if ((isEdit || isView) && itemQuery.isLoading) return <CircularProgress />;
|
|
|
|
return (
|
|
<Box>
|
|
{isList ? (
|
|
<Box>
|
|
{!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && (
|
|
<FilterBar
|
|
fields={config.fields}
|
|
filterableFields={config.filterOptions.fields}
|
|
mode={config.filterOptions?.mode || "server"}
|
|
data={rawData}
|
|
appliedValues={appliedFilters}
|
|
onApply={setAppliedFilters}
|
|
onClear={() => setAppliedFilters({})}
|
|
/>
|
|
)}
|
|
<EnhancedTable
|
|
config={config}
|
|
data={filteredData}
|
|
total={isServer ? totalCount : filteredData.length}
|
|
paginationModel={isServer ? paginationModel : undefined}
|
|
onPaginationModelChange={isServer ? setPaginationModel : undefined}
|
|
loading={listQuery.isFetching}
|
|
onEdit={handleEdit}
|
|
onDelete={handleDelete}
|
|
onCreate={handleCreate}
|
|
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<Paper sx={{ p: 4 }}>
|
|
<GenericForm
|
|
config={config}
|
|
initialData={isCreate ? null : itemQuery.data}
|
|
onSave={handleSave}
|
|
onCancel={() => navigate(`/admin/${config.name}`)}
|
|
loading={createMutation.isPending || updateMutation.isPending}
|
|
readOnly={isView}
|
|
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
|
|
/>
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|