## 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>
186 lines
5.6 KiB
TypeScript
186 lines
5.6 KiB
TypeScript
import SwaggerParser from "@apidevtools/swagger-parser";
|
|
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
|
|
|
/**
|
|
* Maps OpenAPI property types to our internal FieldType
|
|
*/
|
|
function mapOpenApiType(prop: any): FieldType {
|
|
const type = prop.type;
|
|
const format = prop.format;
|
|
|
|
if (format === "date-time") return "datetime";
|
|
if (format === "date") return "date";
|
|
if (prop.enum) return "enum";
|
|
if (
|
|
type === "string" &&
|
|
(prop.description?.toLowerCase().includes("image") ||
|
|
prop.name?.toLowerCase().includes("icon"))
|
|
)
|
|
return "image";
|
|
|
|
switch (type) {
|
|
case "integer":
|
|
case "number":
|
|
return "number";
|
|
case "boolean":
|
|
return "boolean";
|
|
case "object":
|
|
return "object";
|
|
case "array":
|
|
return "array";
|
|
default:
|
|
return "string";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recursively converts OpenAPI schemas to ResourceField map
|
|
*/
|
|
function parseSchemaFields(
|
|
schema: any,
|
|
resourceName: string,
|
|
schemaToResourceMap: Map<any, string>,
|
|
configuration: Record<string, any> = {}
|
|
): Record<string, ResourceField> {
|
|
const fields: Record<string, ResourceField> = {};
|
|
const properties = schema.properties || {};
|
|
const required = schema.required || [];
|
|
const overrides = configuration[resourceName]?.fields || {};
|
|
|
|
for (const [key, prop] of Object.entries(properties) as [string, any]) {
|
|
const type = mapOpenApiType(prop);
|
|
const override = overrides[key];
|
|
|
|
// Explicitly skip 'id' as it's the primary key and handled elsewhere
|
|
if (key === "id" || override?.display === false) continue;
|
|
|
|
fields[key] = {
|
|
type,
|
|
label:
|
|
prop.title ||
|
|
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
|
required: required.includes(key),
|
|
options: prop.enum,
|
|
readOnly:
|
|
prop.readOnly ||
|
|
key === "created_at" ||
|
|
key === "updated_at",
|
|
...override,
|
|
};
|
|
|
|
// STRICT RELATION DETECTION
|
|
// A field is a relation ONLY if its schema object (or items schema)
|
|
// exactly matches a schema that is defined as a resource.
|
|
let targetSchema = prop;
|
|
if (type === "array" && prop.items) {
|
|
targetSchema = prop.items;
|
|
}
|
|
|
|
// Check if this schema object is registered as a resource
|
|
const relation = schemaToResourceMap.get(targetSchema);
|
|
if (relation) {
|
|
fields[key].relation = relation;
|
|
}
|
|
|
|
// Recursively parse nested objects (only if not a relation)
|
|
if (fields[key].type === "object" && prop.properties && !relation) {
|
|
fields[key].schema = parseSchemaFields(prop, resourceName, schemaToResourceMap, configuration);
|
|
}
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* Scans paths to identify resources and their basic configuration
|
|
*/
|
|
export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> {
|
|
// Use SwaggerParser to dereference the spec.
|
|
// Dereferencing preserves object identity for $ref targets.
|
|
const api = await SwaggerParser.dereference(
|
|
new URL("/openapi.json", baseUrl).href
|
|
);
|
|
|
|
const resources: ResourceConfig[] = [];
|
|
const paths = api.paths || {};
|
|
|
|
// Group paths by base resource name
|
|
const resourcePaths: Record<string, any> = {};
|
|
for (const path of Object.keys(paths)) {
|
|
const base = path.split("/")[1];
|
|
if (!base) continue;
|
|
|
|
if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] };
|
|
const methods = Object.keys(paths[path] || {});
|
|
resourcePaths[base].methods.push(...methods);
|
|
|
|
// Identify the list endpoint for this resource
|
|
if (!resourcePaths[base].listPath && !path.includes("{") && paths[path]?.get?.responses?.["200"]) {
|
|
resourcePaths[base].listPath = path;
|
|
}
|
|
}
|
|
|
|
// 1. Identify which schema objects correspond to which resources
|
|
const schemaToResourceMap = new Map<any, string>();
|
|
for (const [name, info] of Object.entries(resourcePaths)) {
|
|
const listPath = info.listPath || `/${name}`;
|
|
const listOp = paths[listPath]?.get;
|
|
if (!listOp) continue;
|
|
|
|
// @ts-ignore
|
|
const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema;
|
|
let schemaObj = responseSchema;
|
|
if (responseSchema?.type === "array" && responseSchema.items) {
|
|
schemaObj = responseSchema.items;
|
|
}
|
|
|
|
if (schemaObj) {
|
|
schemaToResourceMap.set(schemaObj, name);
|
|
resourcePaths[name].schemaObj = schemaObj;
|
|
}
|
|
}
|
|
|
|
// 2. Generate ResourceConfig for each identified resource
|
|
for (const [name, info] of Object.entries(resourcePaths)) {
|
|
const listPath = info.listPath || `/${name}`;
|
|
const listOp = paths[listPath]?.get;
|
|
if (!listOp || !info.schemaObj) continue;
|
|
|
|
const schema = info.schemaObj;
|
|
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
|
|
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
|
|
|
|
const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration);
|
|
|
|
const resourceOverride = configuration[name] || {};
|
|
|
|
const fo = resourceOverride.filterOptions || {};
|
|
|
|
resources.push({
|
|
name,
|
|
label: schema.title || label,
|
|
pluralLabel: pluralLabel,
|
|
endpoint: listPath,
|
|
primaryKey: "id",
|
|
fields,
|
|
pagination: resourceOverride.pagination,
|
|
hidden: resourceOverride.hidden,
|
|
filterOptions: {
|
|
mode: fo.mode || "server",
|
|
fields: fo.fields,
|
|
},
|
|
});
|
|
}
|
|
|
|
// @ts-ignore
|
|
const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "")
|
|
// @ts-ignore
|
|
const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL || ""
|
|
return {
|
|
baseUrl: serverBaseUrl,
|
|
authBaseUrl: authBaseUrl,
|
|
resources,
|
|
profile: profileConfiguration,
|
|
};
|
|
}
|