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"} + + + + {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 }} + /> + + + + + + + + + + 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} + + + setDeleteTarget(null)}> + Delete Fetch Request? + + + This will permanently delete the fetch request and all associated data. + + + + + + + + + ); +} 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 }) => ( + + ))} + + {/* 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. - + + - + + + + {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 }} + /> + + + + + {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} + + + setDeleteTarget(null)}> + Delete Snapshot? + + + This will permanently delete the report snapshot. + + + + + + + +
    + ); +} 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 = {