5 Commits
0.2.1 ... 0.1.1

24 changed files with 100 additions and 1541 deletions

View File

@@ -9,7 +9,6 @@
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/> />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<title>khata - Aetoskia</title> <title>khata - Aetoskia</title>
</head> </head>
<body> <body>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -49,8 +49,8 @@ export default function EnhancedTable({
config, config,
data, data,
total, total,
paginationModel: externalPaginationModel, paginationModel,
onPaginationModelChange: externalOnPaginationModelChange, onPaginationModelChange,
loading = false, loading = false,
onEdit, onEdit,
onDelete, onDelete,
@@ -61,14 +61,6 @@ export default function EnhancedTable({
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate(); const navigate = useNavigate();
const isServer = config.filterOptions?.mode !== "client";
const [internalPaginationModel, setInternalPaginationModel] = React.useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const paginationModel = isServer ? externalPaginationModel : internalPaginationModel;
const onPaginationModelChange = isServer ? externalOnPaginationModelChange : setInternalPaginationModel;
const columns: GridColDef[] = React.useMemo(() => { const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => { const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string'; let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string';
@@ -130,15 +122,6 @@ export default function EnhancedTable({
return cols; return cols;
}, [config, onDelete, navigate, onNavigateToResource]); }, [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) { if (isMobile) {
return ( return (
<Box> <Box>
@@ -149,7 +132,7 @@ export default function EnhancedTable({
</Button> </Button>
</Box> </Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{mobileData.map((row) => ( {data.map((row) => (
<Box key={row[config.primaryKey] || Math.random()}> <Box key={row[config.primaryKey] || Math.random()}>
<MobileCardRow <MobileCardRow
row={row} row={row}
@@ -162,17 +145,6 @@ export default function EnhancedTable({
</Box> </Box>
))} ))}
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2, flexWrap: 'wrap' }}>
<Button size="small" disabled={mobilePage === 0} onClick={() => setMobilePage(mobilePage - 1)}>
Previous
</Button>
<Typography variant="body2" sx={{ alignSelf: 'center', px: 1 }}>
Page {mobilePage + 1} of {mobileTotalPages}
</Typography>
<Button size="small" disabled={mobilePage >= mobileTotalPages - 1} onClick={() => setMobilePage(mobilePage + 1)}>
Next
</Button>
</Box>
</Box> </Box>
); );
} }
@@ -189,18 +161,20 @@ export default function EnhancedTable({
rows={data || []} rows={data || []}
columns={columns} columns={columns}
autoHeight autoHeight
paginationMode={isServer ? 'server' : 'client'} paginationMode={config.pagination ? 'server' : 'client'}
{...(isServer ? { rowCount={(() => {
rowCount: (() => { if (!config.pagination) return data.length;
if (total !== undefined) return total; if (total !== undefined) return total;
const page = paginationModel?.page || 0;
const pageSize = paginationModel?.pageSize || 10; // Graceful fallback for missing total count
if (data.length < pageSize) { const page = paginationModel?.page || 0;
return page * pageSize + data.length; const pageSize = paginationModel?.pageSize || 10;
} if (data.length < pageSize) {
return (page + 2) * pageSize; return page * pageSize + data.length;
})(), }
} : {})} // Enable 'Next' button by pretending there's at least one more page
return (page + 2) * pageSize;
})()}
loading={loading} loading={loading}
paginationModel={paginationModel || { page: 0, pageSize: 10 }} paginationModel={paginationModel || { page: 0, pageSize: 10 }}
onPaginationModelChange={onPaginationModelChange} onPaginationModelChange={onPaginationModelChange}
@@ -260,7 +234,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{field.label} {field.label}
</Typography> </Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}> <Typography variant="body2" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile /> <FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
</Typography> </Typography>
</Box> </Box>

View File

@@ -1,313 +0,0 @@
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<HTMLUListElement>(null);
const scrollPosRef = React.useRef(0);
const [open, setOpen] = React.useState(false);
const [frozenValue, setFrozenValue] = React.useState<string[]>(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 (
<Autocomplete
multiple
freeSolo
disableCloseOnSelect
open={open}
onOpen={toggleDropdown}
onClose={toggleDropdown}
options={sortedOptions}
value={value}
getOptionKey={(option) => 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 (
<li key={key} {...rest}>
{selected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: 'primary.main' }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip
key={key}
{...tagProps}
label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag}
size="small"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>;
})}
{tagValue.length > maxChips && (
<Chip
label={`+${tagValue.length - maxChips}`}
size="small"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>
)}
</>
);
}}
renderInput={(params) => <TextField {...params} placeholder={`Add ${label}...`} />}
sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }}
/>
);
}
function extractOptions(
fieldName: string,
field: ResourceField,
data: any[]
): string[] {
const values = new Set<string>();
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 (
<Box sx={{ display: "flex", gap: 1 }}>
<TextField type="number" placeholder="Min" size="small" value={rangeVal.min ?? ""}
onChange={(e) => onChange("min", e.target.value || undefined)} sx={{ width: 100 }} />
<TextField type="number" placeholder="Max" size="small" value={rangeVal.max ?? ""}
onChange={(e) => onChange("max", e.target.value || undefined)} sx={{ width: 100 }} />
</Box>
);
}
if (filterType === "date-range") {
const rangeVal = (value as { start?: string; end?: string }) || {};
return (
<Box sx={{ display: "flex", gap: 1 }}>
<TextField type="datetime-local" placeholder="From" size="small" value={rangeVal.start ?? ""}
onChange={(e) => onChange("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
<TextField type="datetime-local" placeholder="To" size="small" value={rangeVal.end ?? ""}
onChange={(e) => onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
</Box>
);
}
const selected = Array.isArray(value) ? value : [];
return (
<FilterAutocomplete
options={options}
value={selected}
label={field.label}
onChange={(val) => onChange("value", val.length > 0 ? val : undefined)}
/>
);
}
export interface FilterBarProps {
fields: Record<string, ResourceField>;
filterableFields: string[];
mode: ResourceMode;
data?: any[];
appliedValues: Record<string, any>;
onApply: (values: Record<string, any>) => 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<Record<string, any>>(() => ({ ...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 (
<Paper variant="outlined" sx={{ mb: 2, borderRadius: 2, overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 2,
py: 1,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
onClick={() => setOpen((o) => !o)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FilterListIcon fontSize="small" color="action" />
<Typography variant="subtitle2" fontWeight={600}>
{open ? "Hide Filters" : "Show Filters"}
</Typography>
</Box>
{activeCount > 0 && (
<Typography variant="caption" color="primary" fontWeight={600}>
{activeCount} active
</Typography>
)}
</Box>
{open && (
<Box sx={{ px: 2, pb: 2, borderTop: "1px solid", borderColor: "divider", pt: 2 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, alignItems: "flex-end" }}>
{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 (
<Box key={fieldName} sx={{ display: "flex", flexDirection: "column", flex: { xs: '0 0 100%', sm: 1 }, minWidth: { sm: 200 } }}>
<Box sx={{ typography: "caption", mb: 0.5, color: "text.secondary" }}>
{field.label}
</Box>
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
updateDraft(fieldName, key, val)
)}
</Box>
);
})}
</Box>
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
<Button variant="contained" onClick={handleApply}>
Apply
</Button>
<Button variant="outlined" onClick={handleClear}>
Clear
</Button>
</Box>
</Box>
)}
</Paper>
);
}

View File

@@ -1,12 +1,10 @@
import * as React from 'react'; import * as React from 'react';
import { Box, Paper, CircularProgress } from '@mui/material'; import { Box, Typography, Paper, CircularProgress } from '@mui/material';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import type { ResourceField } from '../types/config';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import GenericForm from './GenericForm'; import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable'; import EnhancedTable from './EnhancedTable';
import FilterBar from './FilterBar'; import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
interface ResourceViewProps { interface ResourceViewProps {
config: ResourceConfig; config: ResourceConfig;
@@ -15,92 +13,6 @@ interface ResourceViewProps {
import { GridPaginationModel } from '@mui/x-data-grid'; 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) { export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
const { id } = useParams(); const { id } = useParams();
const location = useLocation(); const location = useLocation();
@@ -111,36 +23,26 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
const isView = !!id && !isEdit; const isView = !!id && !isEdit;
const isList = !id && !isCreate; const isList = !id && !isCreate;
const isServer = config.filterOptions?.mode !== "client";
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({ const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
page: 0, page: 0,
pageSize: 10, pageSize: 10,
}); });
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config); const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
// Determine query parameters based on pagination config
const queryParams = React.useMemo(() => { const queryParams = React.useMemo(() => {
if (!isServer) return { limit: 10000 }; if (!config.pagination) return {};
return { return {
skip: paginationModel.page * paginationModel.pageSize, skip: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize, limit: paginationModel.pageSize,
}; };
}, [isServer, paginationModel]); }, [config.pagination, paginationModel]);
const listQuery = useList(queryParams); const listQuery = useList(queryParams);
const itemQuery = useRead(id || ""); const itemQuery = useRead(id || "");
const rawData = listQuery.data?.data || []; const paginatedData = listQuery.data || { data: [], total: undefined };
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 createMutation = useCreate();
const updateMutation = useUpdate(); const updateMutation = useUpdate();
const deleteMutation = useDelete(); const deleteMutation = useDelete();
@@ -178,31 +80,18 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
return ( return (
<Box> <Box>
{isList ? ( {isList ? (
<Box> <EnhancedTable
{!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && ( config={config}
<FilterBar data={paginatedData.data || []}
fields={config.fields} total={paginatedData.total}
filterableFields={config.filterOptions.fields} paginationModel={paginationModel}
mode={config.filterOptions?.mode || "server"} onPaginationModelChange={setPaginationModel}
data={rawData} loading={listQuery.isFetching}
appliedValues={appliedFilters} onEdit={handleEdit}
onApply={setAppliedFilters} onDelete={handleDelete}
onClear={() => setAppliedFilters({})} onCreate={handleCreate}
/> onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
)} />
<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 }}> <Paper sx={{ p: 4 }}>
<GenericForm <GenericForm

View File

@@ -30,13 +30,13 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
}); });
// --- READ ONE --- // --- READ ONE ---
const useRead = (id: string, params?: any | null) => const useRead = (id: string | null) =>
useQuery({ useQuery({
queryKey: [name, "detail", id, params], queryKey: [name, "detail", id],
queryFn: async () => { queryFn: async () => {
if (!id || !endpoint) return null; if (!id || !endpoint) return null;
// @ts-ignore // @ts-ignore
const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined); const res = await api.get<T>(`${endpoint}/${id}`);
return res.data; return res.data;
}, },
enabled: !!id && !!endpoint, enabled: !!id && !!endpoint,

View File

@@ -1,8 +1,7 @@
export { default as Admin } from "./Admin"; export { default as Admin } from "./Admin";
export { api, auth, initializeApiClients } from "./api/client"; export { api, auth, initializeApiClients } from "./api/client";
export { getAppConfig } from "./config"; export { getAppConfig } from "./config";
export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config"; export type { AppConfig, ResourceConfig, ResourceField } from "./types/config";
export { AppProvider } from "./providers/AppProvider"; export { AppProvider } from "./providers/AppProvider";
export { ConfigContext, useConfig } from "./providers/ConfigContext"; export { ConfigContext, useConfig } from "./providers/ConfigContext";
export { useResource, useResourceByName } from "./hooks/useResource"; export { useResource, useResourceByName } from "./hooks/useResource";
export { default as FilterBar } from "./components/FilterBar";

View File

@@ -20,11 +20,8 @@ export interface ResourceField {
displayField?: string | string[]; displayField?: string | string[];
formatter?: (value: any) => string; formatter?: (value: any) => string;
relation?: string; // Name of the target resource relation?: string; // Name of the target resource
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
} }
export type ResourceMode = "server" | "client";
export interface ResourceConfig { export interface ResourceConfig {
name: string; name: string;
label: string; label: string;
@@ -34,10 +31,6 @@ export interface ResourceConfig {
fields: Record<string, ResourceField>; fields: Record<string, ResourceField>;
pagination?: boolean; pagination?: boolean;
hidden?: boolean; hidden?: boolean;
filterOptions?: {
mode?: ResourceMode;
fields?: string[];
};
} }
export interface AppConfig { export interface AppConfig {

View File

@@ -7,15 +7,10 @@ export interface FieldOverride {
displayField?: string | string[]; displayField?: string | string[];
display?: boolean; display?: boolean;
formatter?: (value: any) => string; formatter?: (value: any) => string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
} }
export interface ResourceOverride { export interface ResourceOverride {
fields?: Record<string, FieldOverride>; fields?: Record<string, FieldOverride>;
pagination?: boolean; pagination?: boolean;
hidden?: boolean; hidden?: boolean;
filterOptions?: {
mode?: "server" | "client";
fields?: string[];
};
} }

View File

@@ -154,21 +154,15 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
const resourceOverride = configuration[name] || {}; const resourceOverride = configuration[name] || {};
const fo = resourceOverride.filterOptions || {};
resources.push({ resources.push({
name, name,
label: schema.title || label, label: schema.title || label,
pluralLabel: pluralLabel, pluralLabel: pluralLabel,
endpoint: listPath, endpoint: listPath,
primaryKey: "id", primaryKey: "id", // Strict default, no heuristics
fields, fields,
pagination: resourceOverride.pagination, pagination: resourceOverride.pagination,
hidden: resourceOverride.hidden, hidden: resourceOverride.hidden,
filterOptions: {
mode: fo.mode || "server",
fields: fo.fields,
},
}); });
} }

View File

@@ -23,18 +23,6 @@ import {
useReport, useReport,
prepareReport, prepareReport,
} from "./features/report"; } 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() { export default function Dashboard() {
const [state, setState] = React.useState<DashboardState>({ const [state, setState] = React.useState<DashboardState>({
@@ -54,28 +42,7 @@ export default function Dashboard() {
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]); const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
const [loadedTags, setLoadedTags] = 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({ const report = useReport({
snapshot_id: selectedSnapshotId ?? undefined,
periods: ["daily", "weekly", "monthly", "all"], periods: ["daily", "weekly", "monthly", "all"],
flow: state.flow, flow: state.flow,
payee: appliedPayees.length > 0 ? appliedPayees : undefined, payee: appliedPayees.length > 0 ? appliedPayees : undefined,
@@ -83,10 +50,10 @@ export default function Dashboard() {
}); });
React.useEffect(() => { React.useEffect(() => {
if (report.data) { if (report.data?.data) {
setLoadedPayees(prev => { setLoadedPayees(prev => {
const pSet = new Set<string>(prev); const pSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => { report.data.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => { Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => { periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => { p.metric?.transactions?.forEach((t: any) => {
@@ -100,7 +67,7 @@ export default function Dashboard() {
setLoadedTags(prev => { setLoadedTags(prev => {
const tSet = new Set<string>(prev); const tSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => { report.data.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => { Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => { periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => { p.metric?.transactions?.forEach((t: any) => {
@@ -112,7 +79,7 @@ export default function Dashboard() {
return Array.from(tSet).sort(); return Array.from(tSet).sort();
}); });
} }
}, [report.data]); }, [report.data?.data]);
const toggleFlow = const toggleFlow =
React.useCallback(() => { React.useCallback(() => {
@@ -252,7 +219,7 @@ export default function Dashboard() {
return null; return null;
} }
const data = prepareReport(report.data); const data = prepareReport(report.data.data);
return ( return (
<Box> <Box>
<Container> <Container>
@@ -298,21 +265,6 @@ export default function Dashboard() {
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }} sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/> />
</Box> </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 <Button
variant="contained" variant="contained"
size="large" size="large"

View File

@@ -1,336 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
TextField,
Button,
ToggleButtonGroup,
ToggleButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import {
useFetchRequestsList,
useCreateFetchRequest,
useDeleteFetchRequest,
useUploadFile,
} from "./features/fetch-requests";
import type {
FetchRequest,
FetchRequestStatus,
FileSource,
EmailSource,
} from "./features/fetch-requests";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
}
export default function FetchRequests() {
const [sourceType, setSourceType] = React.useState<"file" | "email">("file");
const [accountName, setAccountName] = React.useState("");
const [payorUsername, setPayorUsername] = React.useState("aetos");
const [format, setFormat] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [uploadedPath, setUploadedPath] = React.useState<string | null>(null);
const [fromEmail, setFromEmail] = React.useState("");
const [subject, setSubject] = React.useState("");
const [rawTerms, setRawTerms] = React.useState("");
const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null);
const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList();
const createMutation = useCreateFetchRequest();
const deleteMutation = useDeleteFetchRequest();
const uploadMutation = useUploadFile();
const requests = listData?.data ?? [];
const handleUpload = async () => {
if (!file) return;
const result = await uploadMutation.mutateAsync(file);
if (result?.saved_as) {
setUploadedPath(result.saved_as);
if (!format) setFormat(file.name.split(".").pop() || "");
setSnackbar({ message: `File uploaded: ${result.saved_as}`, severity: "success" });
}
};
const handleCreate = async () => {
if (!accountName) return;
let source: FileSource | EmailSource;
if (sourceType === "file") {
if (!uploadedPath || !format) return;
source = { path: uploadedPath, format } as FileSource;
} else {
if (!format) return;
const emailSource: EmailSource = { format };
if (fromEmail) emailSource.from_email = fromEmail;
if (subject) emailSource.subject = subject;
if (rawTerms.trim()) emailSource.raw_terms = rawTerms.split(",").map((s) => s.trim()).filter(Boolean);
source = emailSource;
}
try {
await createMutation.mutateAsync({
source,
account_name: accountName,
payor_username: payorUsername,
...(startDate ? { start_date: new Date(startDate).toISOString() } : {}),
...(endDate ? { end_date: new Date(endDate).toISOString() } : {}),
});
setSnackbar({ message: "Fetch request created", severity: "success" });
resetForm();
} catch (err: any) {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" });
}
};
const resetForm = () => {
setAccountName("");
setFormat("");
setFile(null);
setUploadedPath(null);
setFromEmail("");
setSubject("");
setRawTerms("");
setStartDate("");
setEndDate("");
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
setSnackbar({ message: "Fetch request deleted", severity: "success" });
} catch {
setSnackbar({ message: "Failed to delete", severity: "error" });
}
setDeleteTarget(null);
};
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline
</Typography>
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
New Fetch Request
</Typography>
<ToggleButtonGroup
value={sourceType}
exclusive
onChange={(_, val) => val && setSourceType(val)}
sx={{ mb: 3 }}
size="small"
>
<ToggleButton value="file">File Upload</ToggleButton>
<ToggleButton value="email">Email Fetch</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{sourceType === "file" ? (
<>
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
<Button variant="outlined" component="label" startIcon={<CloudUploadIcon />}>
Choose File
<input type="file" hidden onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</Button>
<Typography variant="body2" sx={{ flex: 1, color: "text.secondary" }}>
{file ? file.name : "No file selected"}
</Typography>
<Button
variant="contained"
onClick={handleUpload}
disabled={!file || uploadMutation.isPending}
>
{uploadMutation.isPending ? "Uploading..." : "Upload"}
</Button>
</Box>
{uploadedPath && (
<Alert severity="success" sx={{ py: 0 }}>
Uploaded as: {uploadedPath}
</Alert>
)}
<TextField label="Format (csv, pdf, ...)" value={format} onChange={(e) => setFormat(e.target.value)} size="small" />
</>
) : (
<>
<TextField label="Format" value={format} onChange={(e) => setFormat(e.target.value)} size="small" helperText="e.g. email, pdf, csv" />
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
<TextField label="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
<TextField label="Raw Terms" value={rawTerms} onChange={(e) => setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
</>
)}
<TextField label="Account Name" value={accountName} onChange={(e) => setAccountName(e.target.value)} size="small" required />
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
<Box sx={{ display: "flex", gap: 2 }}>
<TextField
label="Start Date"
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
sx={{ flex: 1 }}
/>
<TextField
label="End Date"
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
sx={{ flex: 1 }}
/>
</Box>
<Button
variant="contained"
onClick={handleCreate}
disabled={createMutation.isPending || !accountName || (sourceType === "file" && (!uploadedPath || !format)) || (sourceType === "email" && !format)}
>
{createMutation.isPending ? "Creating..." : "Create Fetch Request"}
</Button>
</Box>
</Paper>
<Paper sx={{ borderRadius: 4 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600}>
Fetch Requests
</Typography>
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : requests.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No fetch requests yet
</Box>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Fingerprint</TableCell>
<TableCell>Source</TableCell>
<TableCell>Account</TableCell>
<TableCell>Status</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{requests.map((req: FetchRequest) => (
<TableRow key={req.id}>
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{req.fingerprint}
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(req.fingerprint);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</TableCell>
<TableCell>
{"path" in req.source ? "File" : "Email"}
</TableCell>
<TableCell>{req.account_name}</TableCell>
<TableCell>
<Chip
label={req.status.replace(/_/g, " ")}
color={statusColors[req.status]}
size="small"
/>
</TableCell>
<TableCell>{formatDate(req.created_at)}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => setDeleteTarget(req)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Paper>
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Fetch Request?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the fetch request and all associated data.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -91,32 +91,6 @@ export default function Header({
<span style={{ flexGrow: 1 }} /> <span style={{ flexGrow: 1 }} />
{/* NAV LINKS */}
<Box
sx={{
display: { xs: "none", md: "flex" },
alignItems: "center",
mr: 2,
gap: 1,
}}
>
{[
{ label: "Dashboard", path: "/dashboard" },
{ label: "Fetch", path: "/fetch-requests" },
{ label: "Reports", path: "/reports" },
].map(({ label, path }) => (
<Button
key={path}
color="inherit"
onClick={() => navigate(path)}
sx={{ textTransform: "none", fontWeight: 500, px: 1.5 }}
size="small"
>
{label}
</Button>
))}
</Box>
{/* AUTH SECTION */} {/* AUTH SECTION */}
{isAuthenticated ? ( {isAuthenticated ? (
<> <>

View File

@@ -1,180 +1,71 @@
import * as React from "react"; import * as React from "react";
import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material"; import { Box, Typography, Button, Container, Stack } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { useTheme, alpha } from "@mui/material/styles";
import { useNavigate } from "react-router-dom"; 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 ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { useAuth } from "../react-auth";
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 (
<Paper
elevation={0}
onClick={() => 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 },
},
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1.5 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: alpha(accent, 0.12),
color: accent,
}}
>
{icon}
</Box>
<Typography variant="subtitle1" fontWeight={700}>
{title}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, lineHeight: 1.6 }}>
{description}
</Typography>
{label && (
<Chip
label={label}
size="small"
variant="outlined"
sx={{ mt: 2, alignSelf: "flex-start", textTransform: "capitalize" }}
/>
)}
</Paper>
);
}
export default function Home() { export default function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const { currentUser } = useAuth();
const features = [
{
icon: <DashboardIcon />,
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: <SyncIcon />,
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: <BarChartIcon />,
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: <SettingsIcon />,
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 ( return (
<Box <Box
sx={{ sx={{
minHeight: "calc(100vh - 64px)", width: "100%",
minHeight: "calc(100vh - 64px)", // accounting for header
display: "flex", display: "flex",
flexDirection: "column", alignItems: "center",
justifyContent: "center",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
"&::before": { "&::before": {
content: '""', content: '""',
position: "absolute", position: "absolute",
top: "-15%", top: "-20%",
left: "-8%", left: "-10%",
width: "45%", width: "50%",
height: "55%", height: "60%",
background: "radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)", background: "radial-gradient(circle, rgba(99,102,241,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0, zIndex: 0,
}, },
"&::after": { "&::after": {
content: '""', content: '""',
position: "absolute", position: "absolute",
bottom: "-15%", bottom: "-20%",
right: "-8%", right: "-10%",
width: "45%", width: "50%",
height: "55%", height: "60%",
background: "radial-gradient(circle, rgba(236,72,153,0.1) 0%, transparent 70%)", background: "radial-gradient(circle, rgba(236,72,153,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0, zIndex: 0,
}, },
}} }}
> >
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1, flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", py: 6 }}> <Container maxWidth="lg" sx={{ position: "relative", zIndex: 1 }}>
<Box <Stack
spacing={4}
alignItems="center"
textAlign="center"
sx={{ sx={{
textAlign: "center", p: { xs: 4, md: 8 },
mb: 6, backdropFilter: "blur(20px)",
backgroundColor: (t) => 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)",
}} }}
> >
<Typography <Typography
variant="h1" variant="h1"
sx={{ sx={{
fontWeight: 800, fontWeight: 800,
fontSize: { xs: "2.5rem", sm: "3.5rem", md: "5rem" }, fontSize: { xs: "3rem", md: "5rem" },
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 50%, #f59e0b 100%)", background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
WebkitBackgroundClip: "text", WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent", WebkitTextFillColor: "transparent",
letterSpacing: "-0.03em",
mb: 2, mb: 2,
}} }}
> >
@@ -182,20 +73,14 @@ export default function Home() {
</Typography> </Typography>
<Typography <Typography
variant="h6" variant="h5"
color="text.secondary" color="text.secondary"
sx={{ sx={{ maxWidth: "600px", lineHeight: 1.6 }}
maxWidth: 580,
mx: "auto",
lineHeight: 1.7,
fontWeight: 400,
fontSize: { xs: "1rem", md: "1.15rem" },
}}
> >
Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow. Your intelligent, extensible financial ledger. Control accounts, manage transactions, and track your data dynamically with our OpenAPI-driven architecture.
</Typography> </Typography>
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: "center", flexWrap: "wrap" }}> <Box mt={4}>
<Button <Button
variant="contained" variant="contained"
size="large" size="large"
@@ -203,44 +88,21 @@ export default function Home() {
onClick={() => navigate("/dashboard")} onClick={() => navigate("/dashboard")}
sx={{ sx={{
px: 4, px: 4,
py: 1.4, py: 1.5,
borderRadius: "50px", borderRadius: "50px",
fontWeight: 700, fontWeight: "bold",
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)", background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
transition: "transform 0.2s ease, box-shadow 0.2s", transition: "transform 0.2s ease-in-out, box-shadow 0.2s",
"&:hover": { "&:hover": {
transform: "translateY(-2px)", transform: "translateY(-3px)",
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.35)}`, boxShadow: (t) => `0 8px 20px ${alpha(t.palette.primary.main, 0.4)}`,
}, },
}} }}
> >
Enter Dashboard Enter Dashboard
</Button> </Button>
<Button
variant="outlined"
size="large"
onClick={() => navigate("/fetch-requests")}
sx={{
px: 4,
py: 1.4,
borderRadius: "50px",
fontWeight: 600,
borderWidth: 2,
"&:hover": { borderWidth: 2 },
}}
>
Import Data
</Button>
</Box> </Box>
</Box> </Stack>
<Grid container spacing={3}>
{features.map((f) => (
<Grid key={f.title} size={{ xs: 12, sm: 6, md: 3 }}>
<FeatureCard {...f} />
</Grid>
))}
</Grid>
</Container> </Container>
</Box> </Box>
); );

View File

@@ -1,273 +0,0 @@
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<ReportSnapshot | null>(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(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 (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Report Snapshots
</Typography>
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Generate New Snapshot
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<FormControlLabel
control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
label="Ignore self-transfers"
/>
<Box sx={{ display: "flex", gap: 2 }}>
<TextField
label="Start Date"
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
sx={{ flex: 1 }}
/>
<TextField
label="End Date"
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
sx={{ flex: 1 }}
/>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<TextField
label="Min Amount"
type="number"
value={minAmount}
onChange={(e) => setMinAmount(e.target.value)}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label="Max Amount"
type="number"
value={maxAmount}
onChange={(e) => setMaxAmount(e.target.value)}
size="small"
sx={{ flex: 1 }}
/>
</Box>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={handleCreate}
disabled={createMutation.isPending}
>
{createMutation.isPending ? "Generating..." : "Generate Snapshot"}
</Button>
{createdSnapshotId && (
<Alert severity="success" onClose={() => setCreatedSnapshotId(null)}>
Snapshot created: <strong>{createdSnapshotId}</strong>. Use it in the Dashboard snapshot selector.
</Alert>
)}
</Box>
</Paper>
<Paper sx={{ borderRadius: 4 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600}>
Existing Snapshots
</Typography>
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : snapshots.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No snapshots yet
</Box>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Snapshot ID</TableCell>
<TableCell>Created</TableCell>
<TableCell>Query</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{snapshots.map((snap: ReportSnapshot) => (
<TableRow key={snap.id}>
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{snap.snapshot_id}
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(snap.snapshot_id);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</TableCell>
<TableCell>{formatDate(snap.created_at)}</TableCell>
<TableCell>
{snap.query ? (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
{snap.query.ignore_self && <Chip label="ignore_self" size="small" variant="outlined" />}
{snap.query.start_date && <Chip label="start" size="small" variant="outlined" />}
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
</Box>
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Paper>
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Snapshot?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the report snapshot.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -1,38 +0,0 @@
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;
}

View File

@@ -1,15 +0,0 @@
export type {
FetchRequest,
FetchRequestCreate,
FetchRequestStatus,
FileSource,
EmailSource,
UploadResult,
} from "./fetch-requests.models";
export {
useFetchRequestsList,
useFetchRequest,
useCreateFetchRequest,
useDeleteFetchRequest,
useUploadFile,
} from "./useFetchRequests";

View File

@@ -1,43 +0,0 @@
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;
},
});
}

View File

@@ -1,9 +0,0 @@
export type {
ReportSnapshot,
ReportQuery,
} from "./report-snapshots.models";
export {
useReportSnapshotsList,
useCreateSnapshot,
useDeleteSnapshot,
} from "./useReportSnapshots";

View File

@@ -1,15 +0,0 @@
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;
}

View File

@@ -1,16 +0,0 @@
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();
}

View File

@@ -9,13 +9,10 @@ export interface ReportParams {
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
const { useRead } = useResourceByName("reports"); const { useList } = useResourceByName("reports");
return useRead( return useList({
params.snapshot_id ? params.snapshot_id : "latest", ...params,
{ periods: params.periods,
...params, });
periods: params.periods,
}
);
} }

View File

@@ -12,8 +12,6 @@ import {
} from "@mui/material"; } from "@mui/material";
import Home from './Home'; import Home from './Home';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import FetchRequests from './FetchRequests';
import ReportSnapshots from './ReportSnapshots';
import { Admin, AppProvider } from '../react-openapi'; import { Admin, AppProvider } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config'; import { configuration, profileConfiguration } from './openapi-config';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
@@ -35,8 +33,6 @@ const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" }, { path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", component: Home, headerTitle: "Home" }, { path: "/home", component: Home, headerTitle: "Home" },
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" }, { 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" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" },
]; ];

View File

@@ -2,14 +2,9 @@ import { ResourceOverride } from "../react-openapi/types/overrides";
export const configuration: Record<string, ResourceOverride> = { export const configuration: Record<string, ResourceOverride> = {
expenses: { expenses: {
filterOptions: {
mode: "client",
fields: ["account", "payee", "tags", "occurred_at", "amount"],
},
fields: { fields: {
payee: { payee: {
displayField: "name", displayField: "name",
filterType: "autocomplete",
}, },
payor: { payor: {
display: false, display: false,
@@ -17,14 +12,11 @@ export const configuration: Record<string, ResourceOverride> = {
}, },
account: { account: {
displayField: "name", displayField: "name",
filterType: "multiselect",
}, },
tags: { tags: {
displayField: ["name", "icon"], displayField: ["name", "icon"],
filterType: "autocomplete",
}, },
occurred_at: { occurred_at: {
filterType: "date-range",
formatter: (val: string) => { formatter: (val: string) => {
const date = new Date(val); const date = new Date(val);
const day = date.getDate(); const day = date.getDate();
@@ -42,14 +34,15 @@ export const configuration: Record<string, ResourceOverride> = {
return `${day}${suffix(day)} ${month} ${year}`; return `${day}${suffix(day)} ${month} ${year}`;
} }
}, },
amount: {
filterType: "number-range",
},
created_at: { created_at: {
display: false display: false
} }
}, },
pagination: true,
}, },
reports: {
hidden: true
}
}; };
export const profileConfiguration = { export const profileConfiguration = {