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 0000000..a3ca36d
Binary files /dev/null and b/public/favicon.png differ
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"}
+
+
+ {uploadMutation.isPending ? "Uploading..." : "Upload"}
+
+
+ {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 }}
+ />
+
+
+
+ {createMutation.isPending ? "Creating..." : "Create Fetch Request"}
+
+
+
+
+
+
+
+ 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}
+
+
+
+
+ );
+}
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 }) => (
+ navigate(path)}
+ sx={{ textTransform: "none", fontWeight: 500, px: 1.5 }}
+ size="small"
+ >
+ {label}
+
+ ))}
+
+
{/* 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.
-
+
navigate("/dashboard")}
sx={{
px: 4,
- py: 1.5,
+ py: 1.4,
borderRadius: "50px",
- fontWeight: "bold",
- background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
- transition: "transform 0.2s ease-in-out, box-shadow 0.2s",
+ fontWeight: 700,
+ background: "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)",
+ transition: "transform 0.2s ease, box-shadow 0.2s",
"&:hover": {
- transform: "translateY(-3px)",
- boxShadow: (t) => `0 8px 20px ${alpha(t.palette.primary.main, 0.4)}`,
+ transform: "translateY(-2px)",
+ boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.35)}`,
},
}}
>
Enter Dashboard
+ navigate("/fetch-requests")}
+ sx={{
+ px: 4,
+ py: 1.4,
+ borderRadius: "50px",
+ fontWeight: 600,
+ borderWidth: 2,
+ "&:hover": { borderWidth: 2 },
+ }}
+ >
+ Import Data
+
-
+
+
+
+ {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 }}
+ />
+
+
+ }
+ onClick={handleCreate}
+ disabled={createMutation.isPending}
+ >
+ {createMutation.isPending ? "Generating..." : "Generate Snapshot"}
+
+
+ {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}
+
+
+
+
+ );
+}
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 = {