16 Commits

Author SHA1 Message Date
cbb44539a5 failure handling 2026-05-30 05:51:34 +05:30
8c99251c14 enrich and save progress with percentage count 2026-05-30 05:32:21 +05:30
0133872586 enrich and save progress with percentage count 2026-05-30 04:38:51 +05:30
4628190858 enrich and save sse 2026-05-30 04:13:46 +05:30
a1a406756f enrich and save sse 2026-05-30 03:59:19 +05:30
9e206fb92b enrich and save sse 2026-05-30 03:43:15 +05:30
be7c2817b7 txn_dict 2026-05-30 03:29:25 +05:30
cb7f20181e richer sses 2026-05-30 03:02:23 +05:30
baffd11a49 validate step for amibiguity resolution 2026-05-29 16:29:40 +05:30
08057f370c sse events 2026-05-29 16:27:55 +05:30
034e0ad29a ambiguity amount balance coloring 2026-05-29 16:19:59 +05:30
8c8808e18b details for counts for steps 2026-05-29 16:14:05 +05:30
acbfca94f2 fixes 2026-05-29 15:55:40 +05:30
ecdfc2094e fixes 2026-05-29 15:05:18 +05:30
c920276293 fetch request steps 2026-05-28 17:52:53 +05:30
d4a79c785d report-fetch-request-ui (#7)
## MR: Fetch Request Pipeline, Report Snapshots, and Admin Filtering

### Summary
Adds fetch request pipeline UI, report snapshot manager, snapshot selector on dashboard, and client-side in-memory filtering for the admin panel. Also overhauls the Home page with feature cards and adds navigation links.

### Changes

**New Pages**
- `/fetch-requests` — Upload bank statements (two-step: upload file, then configure source) or configure email ingestion. Table shows fingerprint (with copy), source type, account, status (color-coded chip), and created date.
- `/reports` — Generate cached report snapshots with filters (ignore self, date range, amount range). Table shows snapshot ID (with copy), creation time, and query summary chips.

**Dashboard**
- Snapshot selector autocomplete dropdown (formatted "Snapshot from {date}"), passes `snapshot_id` to `useReport`
- Styled to match other filter controls (caption above, auto-height)

**Admin — In-Memory Filtering**
- `FilterBar` component: collapsible, Dashboard-style column layout with caption + autocomplete/range/date inputs per filterable field
- `FilterAutocomplete` component: multi-select, free solo, checkmark ticks, selected-first sort frozen while dropdown open (prevents scroll reset)
- `applyClientFilters` in `ResourceView`: handles number range, datetime range, array (object/string elements), non-relation objects, boolean, primitive exact match
- Config-driven via `filterOptions: { mode: "client", fields: [...] }` in `openapi-config.ts`
- Mobile view: each filter takes full width (`flex: "0 0 100%"`), no horizontal squeeze
- `rowCount` omitted in client pagination mode (suppresses MUI X warning)

**Navigation & Home**
- Header nav links: Dashboard, Fetch, Reports
- Home page redesign: gradient hero, "Import Data" CTA, 4 feature cards (Dashboard, Fetch Requests, Report Snapshots, Admin) with accent-colored hover effects

**React-OpenAPI Library**
- `filterOptions` (mode + fields) on `ResourceOverride` and `ResourceConfig` types
- `EnhancedTable` mobile pagination (10 per page with Prev/Next, prevents browser hang with 10000 records)
- `useResource` accepts `filterOptions` from loader

**Misc**
- `public/favicon.png` added, proper `image/png` type in index.html
- 24 files changed, ~1541 insertions, ~100 deletions

### Files Changed (24)
| File | Change |
|------|--------|
| `src/FetchRequests.tsx` | +336 — new page |
| `src/ReportSnapshots.tsx` | +273 — new page |
| `src/features/fetch-requests/` | +96 — models, hooks, index |
| `src/features/report-snapshots/` | +40 — models, hooks, index |
| `src/Dashboard.tsx` | +58 — snapshot selector |
| `src/Home.tsx` | +224 — redesign with feature cards |
| `src/Header.tsx` | +26 — nav links |
| `src/main.jsx` | +4 — routes |
| `react-openapi/components/FilterBar.tsx` | +313 — new component |
| `react-openapi/components/ResourceView.tsx` | +151 — client filtering |
| `react-openapi/components/EnhancedTable.tsx` | +62 — mobile pagination |
| `react-openapi/types/config.ts` | +7 — filterOptions type |
| `react-openapi/types/overrides.ts` | +5 — filterOptions type |
| `react-openapi/utils/openapi_loader.ts` | +8 — load filterOptions |
| `react-openapi/hooks/useResource.ts` | +6 — filterOptions passthrough |
| `react-openapi/index.ts` | +3 — exports |
| `src/openapi-config.ts` | +15 — expenses config |
| `src/features/report/useReport.ts` | +13 — snapshot_id support |
| `index.html` | +1 — favicon link |
| `public/favicon.png` | +2910 bytes |

Reviewed-on: #7
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-05-24 17:23:02 +00:00
20 changed files with 1658 additions and 125 deletions

View File

@@ -9,6 +9,7 @@
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/png" href="/favicon.png" />
<title>khata - Aetoskia</title> <title>khata - Aetoskia</title>
</head> </head>
<body> <body>

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -46,6 +46,10 @@ export const api = {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.delete(...args); return _api.delete(...args);
}, },
patch: (...args: Parameters<AxiosInstance["patch"]>) => {
if (!_api) throw new Error("API client not initialized");
return _api.patch(...args);
},
}; };
export const auth = { export const auth = {

View File

@@ -49,8 +49,8 @@ export default function EnhancedTable({
config, config,
data, data,
total, total,
paginationModel, paginationModel: externalPaginationModel,
onPaginationModelChange, onPaginationModelChange: externalOnPaginationModelChange,
loading = false, loading = false,
onEdit, onEdit,
onDelete, onDelete,
@@ -60,6 +60,14 @@ export default function EnhancedTable({
const theme = useTheme(); const theme = useTheme();
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]) => {
@@ -122,6 +130,15 @@ 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>
@@ -132,7 +149,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 }}>
{data.map((row) => ( {mobileData.map((row) => (
<Box key={row[config.primaryKey] || Math.random()}> <Box key={row[config.primaryKey] || Math.random()}>
<MobileCardRow <MobileCardRow
row={row} row={row}
@@ -145,6 +162,17 @@ 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>
); );
} }
@@ -161,20 +189,18 @@ export default function EnhancedTable({
rows={data || []} rows={data || []}
columns={columns} columns={columns}
autoHeight autoHeight
paginationMode={config.pagination ? 'server' : 'client'} paginationMode={isServer ? 'server' : 'client'}
rowCount={(() => { {...(isServer ? {
if (!config.pagination) return data.length; rowCount: (() => {
if (total !== undefined) return total; if (total !== undefined) return total;
const page = paginationModel?.page || 0;
// Graceful fallback for missing total count const pageSize = paginationModel?.pageSize || 10;
const page = paginationModel?.page || 0; if (data.length < pageSize) {
const pageSize = paginationModel?.pageSize || 10; return page * pageSize + data.length;
if (data.length < pageSize) { }
return page * pageSize + data.length; return (page + 2) * pageSize;
} })(),
// 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}
@@ -234,7 +260,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" sx={{ fontWeight: 500, wordBreak: 'break-all' }}> <Typography variant="body2" component="div" 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

@@ -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<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,10 +1,12 @@
import * as React from 'react'; 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 { 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 { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom'; import FilterBar from './FilterBar';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
interface ResourceViewProps { interface ResourceViewProps {
config: ResourceConfig; config: ResourceConfig;
@@ -13,36 +15,132 @@ 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();
const navigate = useNavigate(); const navigate = useNavigate();
const isCreate = location.pathname.endsWith('/create'); const isCreate = location.pathname.endsWith('/create');
const isEdit = location.pathname.includes('/edit/'); const isEdit = location.pathname.includes('/edit/');
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 (!config.pagination) return {}; if (!isServer) return { limit: 10000 };
return { return {
skip: paginationModel.page * paginationModel.pageSize, skip: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize, limit: paginationModel.pageSize,
}; };
}, [config.pagination, paginationModel]); }, [isServer, paginationModel]);
const listQuery = useList(queryParams); const listQuery = useList(queryParams);
const itemQuery = useRead(id || ""); 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 createMutation = useCreate();
const updateMutation = useUpdate(); const updateMutation = useUpdate();
const deleteMutation = useDelete(); const deleteMutation = useDelete();
@@ -80,18 +178,31 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
return ( return (
<Box> <Box>
{isList ? ( {isList ? (
<EnhancedTable <Box>
config={config} {!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && (
data={paginatedData.data || []} <FilterBar
total={paginatedData.total} fields={config.fields}
paginationModel={paginationModel} filterableFields={config.filterOptions.fields}
onPaginationModelChange={setPaginationModel} mode={config.filterOptions?.mode || "server"}
loading={listQuery.isFetching} data={rawData}
onEdit={handleEdit} appliedValues={appliedFilters}
onDelete={handleDelete} onApply={setAppliedFilters}
onCreate={handleCreate} onClear={() => setAppliedFilters({})}
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

@@ -73,6 +73,23 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
}, },
}); });
// --- PATCH ---
const usePatch = () =>
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.patch<T>(`${endpoint}/${id}`, data);
return res.data;
},
onSuccess: (updatedItem) => {
// @ts-ignore
const id = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
},
});
// --- DELETE --- // --- DELETE ---
const useDelete = () => const useDelete = () =>
useMutation({ useMutation({
@@ -136,6 +153,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
useMe, useMe,
useCreate, useCreate,
useUpdate, useUpdate,
usePatch,
useUpdateMe, useUpdateMe,
useDelete, useDelete,
getListQueryOptions, getListQueryOptions,

View File

@@ -1,7 +1,8 @@
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 } from "./types/config"; export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } 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,8 +20,11 @@ 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;
@@ -31,6 +34,10 @@ 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,10 +7,15 @@ 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,15 +154,21 @@ 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", // Strict default, no heuristics primaryKey: "id",
fields, fields,
pagination: resourceOverride.pagination, pagination: resourceOverride.pagination,
hidden: resourceOverride.hidden, hidden: resourceOverride.hidden,
filterOptions: {
mode: fo.mode || "server",
fields: fo.fields,
},
}); });
} }

View File

@@ -309,8 +309,7 @@ export default function Dashboard() {
getOptionLabel={(o) => o.label} getOptionLabel={(o) => o.label}
isOptionEqualToValue={(o, v) => o.value === v.value} isOptionEqualToValue={(o, v) => o.value === v.value}
renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />} renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 40, py: 0 } }} sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
size="small"
/> />
</Box> </Box>

675
src/FetchRequestDetail.tsx Normal file
View File

@@ -0,0 +1,675 @@
import * as React from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Container,
Paper,
Typography,
Button,
Chip,
CircularProgress,
Alert,
Stepper,
Step,
StepLabel,
StepIcon,
LinearProgress,
IconButton,
Snackbar,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import {
useFetchRequest,
useUpdateFetchRequest,
useFetchRequestAmbiguities,
useResolveAmbiguity,
} from "./features/fetch-requests";
import type {
FetchRequestStatus,
SSEEvent,
ProgressMessage,
} from "./features/fetch-requests";
import { RETRY_MAX } from "./features/fetch-requests";
import { useConfig } from "../react-openapi";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
paused: "warning",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
pending: <PlayArrowIcon sx={{ fontSize: 16 }} />,
processing: <CircularProgress size={14} />,
paused: <WarningAmberIcon sx={{ fontSize: 16 }} />,
raw_expenses_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
enriched_done: <CheckCircleIcon sx={{ fontSize: 16 }} />,
completed: <CheckCircleIcon sx={{ fontSize: 16 }} />,
failed: <ErrorIcon sx={{ fontSize: 16 }} />,
};
function computeProgressPercent(
status: FetchRequestStatus,
liveCount: number,
seenSteps: Set<string>,
stepStats: Record<string, number>,
txnBlockCount: number,
txnDictCount: number,
): number {
if (status === "pending") return 0;
if (status === "completed") return 100;
let pct = 0;
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
if (txnBlockCount > 0) {
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);
pct += Math.min(1, current / txnBlockCount) * 20;
}
if (txnDictCount > 0) {
pct += Math.min(1, (stepStats.enrich_count ?? 0) / txnDictCount) * 50;
pct += Math.min(1, (stepStats.save_count ?? 0) / txnDictCount) * 20;
}
return Math.round(Math.min(100, pct));
}
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
function computeActiveStep(status: FetchRequestStatus, seenSteps: Set<string>): number {
if (status === "completed") return stepLabels.length;
if (seenSteps.has("save_expenses/completed") || seenSteps.has("complete/completed")) return stepLabels.length;
if (seenSteps.has("save_expenses") || seenSteps.has("complete")) return 3;
if (seenSteps.has("enrich/completed")) return 3;
if (seenSteps.has("enrich")) return 2;
if (seenSteps.has("txn_dicts/completed") || status === "raw_expenses_done") return 2;
if (seenSteps.has("txn_dicts")) return 1;
if (seenSteps.has("txn_blocks/completed")) return 1;
if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) return 0;
if (status === "processing" || status === "paused") return 0;
return -1;
}
function formatProgressMessage(msg: ProgressMessage): string {
if (msg.lines !== undefined) return `${msg.lines} lines`;
if (msg.blocks !== undefined) return `${msg.blocks} blocks`;
if (msg.count !== undefined && msg.unit) return `${msg.count} ${msg.unit}`;
if (msg.count !== undefined) return `${msg.count} items`;
if (msg.raw_ocr_line) return `"${msg.raw_ocr_line.slice(0, 60)}${msg.raw_ocr_line.length > 60 ? "…" : ""}"`;
if (msg.error) return msg.error.slice(0, 80);
return "";
}
function sseIcon(status: SSEEvent["status"]) {
switch (status) {
case "started": return <CircularProgress size={14} />;
case "completed": return <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />;
case "failed": return <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />;
case "skipped": return <RemoveCircleOutlineIcon sx={{ fontSize: 16, color: "text.disabled" }} />;
case "paused": return <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />;
case "progress": return (
<FiberManualRecordIcon
sx={{ fontSize: 14, color: "info.main" }}
/>
);
}
}
function isMathValid(candidate: { amount: number; balance: number }, prevBalance: number) {
return (
candidate.balance === prevBalance + candidate.amount ||
candidate.balance === prevBalance - candidate.amount ||
Math.abs(candidate.balance - (prevBalance + candidate.amount)) < 0.01 ||
Math.abs(candidate.balance - (prevBalance - candidate.amount)) < 0.01
);
}
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const config = useConfig();
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useFetchRequest(id!);
const updateMutation = useUpdateFetchRequest();
const resolveMutation = useResolveAmbiguity();
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
const [sseEvents, setSseEvents] = React.useState<SSEEvent[]>([]);
const [sseConnected, setSseConnected] = React.useState(false);
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [stepStats, setStepStats] = React.useState<Record<string, number>>({});
const [failNotif, setFailNotif] = React.useState<string | null>(null);
const sseRef = React.useRef<EventSource | null>(null);
const feedRef = React.useRef<HTMLDivElement>(null);
const txnBlockCount = React.useMemo(() => {
const blocks = (fetchRequest as any)?.source?.txn_blocks;
if (!blocks) return 0;
return Object.values(blocks).reduce(
(sum: number, list: any) => sum + (Array.isArray(list) ? list.length : 0),
0,
);
}, [fetchRequest]);
const stepMessages = React.useMemo(() => {
const msgs: Record<number, string> = {};
const source = (fetchRequest as any)?.source;
const rawLineCount = stepStats.raw_lines ?? (source?.raw_lines?.length ?? 0);
if (rawLineCount) msgs[0] = `${rawLineCount}`;
const sourceDictCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
const dictLive = liveParsedCount ?? stepStats.txn_dicts ?? 0;
const dictCurrent = Math.max(dictLive, sourceDictCount);
if (dictCurrent && txnBlockCount) msgs[1] = `${dictCurrent}/${txnBlockCount}`;
else if (dictCurrent) msgs[1] = `${dictCurrent}`;
const txnDictDenom = stepStats.txn_dicts ?? sourceDictCount;
if (stepStats.enrich_count && txnDictDenom) msgs[2] = `${stepStats.enrich_count}/${txnDictDenom}`;
else if (stepStats.enrich_count) msgs[2] = `${stepStats.enrich_count}`;
if (stepStats.save_count && txnDictDenom) msgs[3] = `${stepStats.save_count}/${txnDictDenom}`;
else if (stepStats.save_count) msgs[3] = `${stepStats.save_count}`;
return msgs;
}, [fetchRequest, stepStats, liveParsedCount, txnBlockCount]);
React.useEffect(() => {
if (!id || !config?.baseUrl) return;
const url = `${config.baseUrl}/fetch-requests/${id}/events`;
const es = new EventSource(url);
sseRef.current = es;
es.onopen = () => setSseConnected(true);
es.onerror = () => setSseConnected(false);
es.onmessage = (event) => {
try {
const parsed: SSEEvent = JSON.parse(event.data);
setSseEvents((prev) => [...prev, parsed]);
if (parsed.status === "progress" && parsed.message.count !== undefined) {
if (parsed.step === "txn_dicts") setLiveParsedCount(parsed.message.count);
if (parsed.step === "enrich") setStepStats((prev) => ({ ...prev, enrich_count: parsed.message.count! }));
if (parsed.step === "save_expenses") setStepStats((prev) => ({ ...prev, save_count: parsed.message.count! }));
}
if (parsed.status === "completed" && parsed.message.count !== undefined) {
const stats: Record<string, number> = {};
if (parsed.step === "raw_lines" && parsed.message.lines !== undefined) stats.raw_lines = parsed.message.lines;
if (parsed.step === "txn_blocks" && parsed.message.blocks !== undefined) stats.txn_blocks = parsed.message.blocks;
if (parsed.step === "txn_dicts") stats.txn_dicts = parsed.message.count;
if (parsed.step === "enrich") stats.enrich_count = parsed.message.count;
if (parsed.step === "save_expenses") stats.save_count = parsed.message.count;
if (Object.keys(stats).length) {
setStepStats((prev) => ({ ...prev, ...stats }));
}
}
if (parsed.status === "paused") {
refetchRequest();
refetchAmbiguities();
}
if (parsed.status === "failed") {
setFailNotif(parsed.message.error || "Fetch request failed");
refetchRequest();
}
if (parsed.status === "completed" || parsed.step === "resume_extract") {
refetchRequest();
}
} catch {
// ignore malformed events
}
};
return () => {
es.close();
sseRef.current = null;
};
}, [id, config?.baseUrl]);
React.useEffect(() => {
if (feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
}, [sseEvents]);
const displayEvents = React.useMemo(() => {
const progressSteps = new Set(["txn_dicts", "enrich", "save_expenses"]);
const lastProgressIdx: Record<string, number> = {};
for (let i = sseEvents.length - 1; i >= 0; i--) {
const e = sseEvents[i];
if (progressSteps.has(e.step) && e.status === "progress" && lastProgressIdx[e.step] === undefined) {
lastProgressIdx[e.step] = i;
}
}
const terminalStatuses = new Set(["completed", "skipped", "paused", "failed"]);
return sseEvents.filter((e, i) => {
if (progressSteps.has(e.step) && e.status === "progress") return i === lastProgressIdx[e.step];
if (e.status === "started") {
return !sseEvents.slice(i + 1).some(
(later) => later.step === e.step && terminalStatuses.has(later.status),
);
}
return true;
});
}, [sseEvents]);
const seenSteps = React.useMemo(() => {
const steps = new Set<string>();
for (const evt of sseEvents) {
steps.add(evt.step);
if (evt.status === "completed") steps.add(`${evt.step}/completed`);
if (evt.status === "failed") steps.add(`${evt.step}/failed`);
if (evt.status === "started") steps.add(`${evt.step}/started`);
if (evt.status === "progress") steps.add(`${evt.step}/progress`);
}
return steps;
}, [sseEvents]);
const displayParsedCount = React.useMemo(() => {
if (liveParsedCount && liveParsedCount > 0) return liveParsedCount;
const source = (fetchRequest as any)?.source;
const persistedCount = source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
if (persistedCount > 0) return persistedCount;
const dicts = source?.txn_dicts;
if (Array.isArray(dicts) && dicts.length > 0) return dicts.length;
return 0;
}, [liveParsedCount, fetchRequest]);
const txnDictCount = React.useMemo(() => {
const source = (fetchRequest as any)?.source;
if (stepStats.txn_dicts && stepStats.txn_dicts > 0) return stepStats.txn_dicts;
return source?.txn_dict_count ?? source?.txn_dicts_count ?? 0;
}, [fetchRequest, stepStats]);
const progressPercent = React.useMemo(
() => computeProgressPercent(
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
displayParsedCount,
seenSteps,
stepStats,
txnBlockCount,
txnDictCount,
),
[fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount],
);
const handleRetry = async () => {
if (!id) return;
try {
await updateMutation.mutateAsync({ id, data: { status: "pending" } });
} catch {
// handled by react query
}
};
const handleResolve = async (ambiguity: any, candidate: { amount: number; balance: number }) => {
await resolveMutation.mutateAsync({
ambiguityId: ambiguity.id,
payload: { chosen: { amount: candidate.amount, balance: candidate.balance } },
});
refetchAmbiguities();
};
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
<CircularProgress />
</Box>
);
}
if (fetchError || !fetchRequest) {
return (
<Container sx={{ mt: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back
</Button>
<Alert severity="error">Failed to load fetch request</Alert>
</Container>
);
}
const req = fetchRequest as any;
const activeStep = computeActiveStep(req.status as FetchRequestStatus, seenSteps);
const retryCount = req.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const pendingAmbiguities = ambiguities?.filter((a: any) => a.status === "pending") ?? [];
const resolvedAmbiguities = ambiguities?.filter((a: any) => a.status === "resolved") ?? [];
const hasAmbiguities = ambiguities && ambiguities.length > 0;
const allResolved = hasAmbiguities && pendingAmbiguities.length === 0;
const ambiguitiesLoading = !ambiguities;
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back to Fetch Requests
</Button>
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip
icon={statusIcons[req.status as FetchRequestStatus] as any}
label={req.status.replace(/_/g, " ")}
color={statusColors[req.status as FetchRequestStatus]}
/>
<Typography variant="h6" fontWeight={600}>{req.account_name}</Typography>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</Box>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
<Box>
<Typography variant="caption" color="text.secondary">Date Range</Typography>
<Typography variant="body2">
{(req as any).start_date ? new Date((req as any).start_date).toLocaleDateString() : "?"} {(req as any).end_date ? new Date((req as any).end_date).toLocaleDateString() : "?"}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Created</Typography>
<Typography variant="body2">{new Date(req.created_at).toLocaleString()}</Typography>
</Box>
{req.completed_at && (
<Box>
<Typography variant="caption" color="text.secondary">Completed</Typography>
<Typography variant="body2">{new Date(req.completed_at).toLocaleString()}</Typography>
</Box>
)}
</Box>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Overall Progress
</Typography>
{["processing", "paused"].includes(req.status) && displayParsedCount > 0 && (
<Typography variant="caption" fontWeight={600} color="info.main">
Validated: {displayParsedCount} transactions
</Typography>
)}
</Box>
<LinearProgress
variant="determinate"
value={progressPercent}
color={req.status === "failed" ? "error" : req.status === "completed" ? "success" : "primary"}
sx={{ borderRadius: 1, height: 8, transition: "width 0.3s ease" }}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.25, display: "block" }}>
{progressPercent}%
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary">
Retries: {retryCount}/{RETRY_MAX}
</Typography>
<LinearProgress
variant="determinate"
value={(retryCount / RETRY_MAX) * 100}
color={isRetryExhausted ? "error" : "primary"}
sx={{ mt: 0.5, borderRadius: 1, height: 6 }}
/>
</Box>
{req.status === "failed" && !isRetryExhausted && (
<Button
variant="outlined"
size="small"
startIcon={<ReplayIcon />}
onClick={handleRetry}
disabled={updateMutation.isPending}
>
Retry
</Button>
)}
</Box>
</Paper>
{req.status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{req.error_message}
</Alert>
)}
{isRetryExhausted && req.status === "failed" && (
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Max retries reached no further retry attempts will be made.
</Alert>
)}
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Pipeline Progress
</Typography>
<Stepper activeStep={activeStep} alternativeLabel>
{stepLabels.map((label, index) => {
const isCompleted = index < activeStep;
const isActive = index === activeStep;
const isPaused = req.status === "paused" && isActive;
const isFailed = req.status === "failed" && isActive;
let icon: React.ReactNode;
if (isCompleted) {
icon = <CheckCircleIcon sx={{ color: "success.main" }} />;
} else if (isFailed) {
icon = <ErrorIcon sx={{ color: "error.main" }} />;
} else if (isPaused) {
icon = <WarningAmberIcon sx={{ color: "warning.main" }} />;
} else if (isActive) {
icon = <CircularProgress size={20} />;
} else {
icon = <Typography variant="caption" color="text.disabled">{index + 1}</Typography>;
}
const stepMsg = stepMessages[index];
return (
<Step key={label}>
<StepLabel
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
>
<Typography variant="body2" fontWeight={600}>{label}</Typography>
{stepMsg && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
{stepMsg}
</Typography>
)}
</StepLabel>
</Step>
);
})}
</Stepper>
</Paper>
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
Progress Events
</Typography>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: sseConnected ? "success.main" : "error.main",
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary">
{sseConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
<Box
ref={feedRef}
sx={{
maxHeight: 300,
overflowY: "auto",
p: 2,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
{displayEvents.length === 0 ? (
<Typography variant="body2" color="text.disabled" sx={{ textAlign: "center", py: 2 }}>
Waiting for events...
</Typography>
) : (
displayEvents.map((evt, i) => (
<Box
key={i}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
p: 1,
borderRadius: 2,
bgcolor: "action.hover",
}}
>
{sseIcon(evt.status)}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{evt.step.replace(/_/g, " ")}
</Typography>
{evt.message && formatProgressMessage(evt.message) && (
<Typography variant="caption" color="text.secondary">
{formatProgressMessage(evt.message)}
</Typography>
)}
</Box>
<Typography variant="caption" color="text.disabled">
{new Date().toLocaleTimeString()}
</Typography>
</Box>
))
)}
</Box>
</Paper>
{hasAmbiguities && (
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Ambiguity Resolution
</Typography>
{allResolved ? (
<Alert severity="success" sx={{ mb: 2, borderRadius: 2 }}>
All ambiguities resolved pipeline will resume on next poll cycle
</Alert>
) : (
<Alert severity="warning" sx={{ mb: 2, borderRadius: 2 }}>
Pipeline paused resolve ambiguities to continue
</Alert>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ambiguities.map((ambiguity: any) => {
const isResolved = ambiguity.status === "resolved";
return (
<Paper
key={ambiguity.id}
sx={{
p: 2,
borderRadius: 3,
border: 1,
borderColor: isResolved ? "success.main" : "divider",
opacity: isResolved ? 0.8 : 1,
}}
variant="outlined"
>
<Box sx={{ fontFamily: "monospace", fontSize: "0.85rem", mb: 1.5, p: 1, bgcolor: "grey.900", borderRadius: 1, color: "grey.100" }}>
{ambiguity.line}
</Box>
<Box sx={{ display: "flex", gap: 3, mb: 1.5, flexWrap: "wrap" }}>
<Box>
<Typography variant="caption" color="text.secondary">OCR Amount</Typography>
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
{ambiguity.ocr_amount}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">OCR Balance</Typography>
<Typography variant="body2" sx={{ textDecoration: "line-through", color: "text.secondary" }}>
{ambiguity.ocr_balance}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Previous Balance</Typography>
<Typography variant="body2">{ambiguity.prev_balance}</Typography>
</Box>
</Box>
{isResolved ? (
<Alert severity="success" sx={{ py: 0.5, borderRadius: 2 }} icon={<CheckCircleIcon />}>
Resolved: {ambiguity.chosen?.amount} / {ambiguity.chosen?.balance}
</Alert>
) : (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{ambiguity.candidates.map((candidate: any, ci: number) => {
const isCredit = candidate.amount > 0;
const isDebit = candidate.amount < 0;
const cColor = isCredit ? "success.main" : isDebit ? "error.main" : undefined;
return (
<Button
key={ci}
variant="outlined"
size="small"
onClick={() => handleResolve(ambiguity, candidate)}
disabled={resolveMutation.isPending}
sx={{
borderColor: cColor,
color: cColor,
"&:hover": cColor ? { borderColor: cColor } : undefined,
}}
>
{candidate.amount} / {candidate.balance}
</Button>
);
})}
</Box>
)}
</Paper>
);
})}
</Box>
</Paper>
)}
<Snackbar
open={!!failNotif}
autoHideDuration={6000}
onClose={() => setFailNotif(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert severity="error" onClose={() => setFailNotif(null)} sx={{ borderRadius: 2 }}>
{failNotif}
</Alert>
</Snackbar>
</Container>
);
}

View File

@@ -24,13 +24,28 @@ import {
DialogContent, DialogContent,
DialogContentText, DialogContentText,
DialogActions, DialogActions,
Tooltip,
Select,
MenuItem,
InputLabel,
FormControl,
OutlinedInput,
Autocomplete,
} from "@mui/material"; } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import ScheduleIcon from "@mui/icons-material/Schedule";
import HourglassEmptyIcon from "@mui/icons-material/HourglassEmpty";
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import { import {
useFetchRequestsList, useFetchRequestsList,
useCreateFetchRequest, useCreateFetchRequest,
useUpdateFetchRequest,
useDeleteFetchRequest, useDeleteFetchRequest,
useUploadFile, useUploadFile,
} from "./features/fetch-requests"; } from "./features/fetch-requests";
@@ -40,22 +55,49 @@ import type {
FileSource, FileSource,
EmailSource, EmailSource,
} from "./features/fetch-requests"; } from "./features/fetch-requests";
import { RETRY_MAX } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
import { useResourceByName, useConfig } from "../react-openapi";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = { const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default", pending: "default",
processing: "info", processing: "info",
paused: "warning",
raw_expenses_done: "primary", raw_expenses_done: "primary",
enriched_done: "warning", enriched_done: "warning",
completed: "success", completed: "success",
failed: "error", failed: "error",
}; };
const statusIcons: Record<FetchRequestStatus, React.ReactNode> = {
pending: <ScheduleIcon sx={{ fontSize: 16 }} />,
processing: <CircularProgress size={14} sx={{ mr: 0.5 }} />,
paused: <WarningAmberIcon sx={{ fontSize: 16, color: "warning.main" }} />,
raw_expenses_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
enriched_done: <HourglassEmptyIcon sx={{ fontSize: 16 }} />,
completed: <CheckCircleIcon sx={{ fontSize: 16, color: "success.main" }} />,
failed: <ErrorIcon sx={{ fontSize: 16, color: "error.main" }} />,
};
function formatDate(iso: string) { function formatDate(iso: string) {
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleString(); return d.toLocaleString();
} }
function formatDateRange(start?: string, end?: string) {
if (!start && !end) return "—";
const s = start ? new Date(start).toLocaleDateString() : "?";
const e = end ? new Date(end).toLocaleDateString() : "?";
return `${s}${e}`;
}
function shortId(fp: string) {
return fp.length > 8 ? fp.slice(0, 8) + "…" : fp;
}
export default function FetchRequests() { export default function FetchRequests() {
const navigate = useNavigate();
const [sourceType, setSourceType] = React.useState<"file" | "email">("file"); const [sourceType, setSourceType] = React.useState<"file" | "email">("file");
const [accountName, setAccountName] = React.useState(""); const [accountName, setAccountName] = React.useState("");
const [payorUsername, setPayorUsername] = React.useState("aetos"); const [payorUsername, setPayorUsername] = React.useState("aetos");
@@ -70,8 +112,27 @@ export default function FetchRequests() {
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null); const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null); const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null);
const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList(); const [statusFilter, setStatusFilter] = React.useState<string[]>([]);
const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList({
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
});
const { useList: useAccountsList } = useResourceByName("accounts");
const { data: accountsData } = useAccountsList();
const accountOptions: string[] = React.useMemo(() => {
return (accountsData?.data ?? []).map((a: any) => a.name).filter(Boolean);
}, [accountsData]);
const config = useConfig();
const fetchRes = config?.resources.find((r: any) => r.name === "fetch-requests");
const formatOptions: string[] = (fetchRes?.fields?.source?.schema?.format?.options as string[]) ?? ["axis", "icici_ocr"];
const createMutation = useCreateFetchRequest(); const createMutation = useCreateFetchRequest();
const updateMutation = useUpdateFetchRequest();
const deleteMutation = useDeleteFetchRequest(); const deleteMutation = useDeleteFetchRequest();
const uploadMutation = useUploadFile(); const uploadMutation = useUploadFile();
@@ -105,7 +166,7 @@ export default function FetchRequests() {
} }
try { try {
await createMutation.mutateAsync({ const result = await createMutation.mutateAsync({
source, source,
account_name: accountName, account_name: accountName,
payor_username: payorUsername, payor_username: payorUsername,
@@ -114,8 +175,13 @@ export default function FetchRequests() {
}); });
setSnackbar({ message: "Fetch request created", severity: "success" }); setSnackbar({ message: "Fetch request created", severity: "success" });
resetForm(); resetForm();
navigate(`/fetch-requests/${result.id}`);
} catch (err: any) { } catch (err: any) {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" }); if (err?.response?.status === 409) {
setSnackbar({ message: "Duplicate — same fingerprint already exists", severity: "error" });
} else {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" });
}
} }
}; };
@@ -131,6 +197,15 @@ export default function FetchRequests() {
setEndDate(""); setEndDate("");
}; };
const handleRetry = async (req: FetchRequest) => {
try {
await updateMutation.mutateAsync({ id: req.id, data: { status: "pending" } });
setSnackbar({ message: "Retrying fetch request", severity: "success" });
} catch {
setSnackbar({ message: "Failed to retry", severity: "error" });
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return; if (!deleteTarget) return;
try { try {
@@ -142,6 +217,8 @@ export default function FetchRequests() {
setDeleteTarget(null); setDeleteTarget(null);
}; };
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
return ( return (
<Container sx={{ mt: 4, mb: 4 }}> <Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom> <Typography variant="h5" fontWeight="bold" gutterBottom>
@@ -188,37 +265,61 @@ export default function FetchRequests() {
Uploaded as: {uploadedPath} Uploaded as: {uploadedPath}
</Alert> </Alert>
)} )}
<TextField label="Format (csv, pdf, ...)" value={format} onChange={(e) => setFormat(e.target.value)} size="small" /> <FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
</> </>
) : ( ) : (
<> <>
<TextField label="Format" value={format} onChange={(e) => setFormat(e.target.value)} size="small" helperText="e.g. email, pdf, csv" /> <FormControl size="small">
<InputLabel>Format</InputLabel>
<Select value={format} onChange={(e) => setFormat(e.target.value)} label="Format">
{formatOptions.map((opt) => (
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
))}
</Select>
</FormControl>
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" /> <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="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="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 /> <Autocomplete
options={accountOptions}
value={accountName || null}
onChange={(_, val) => setAccountName(val ?? "")}
renderInput={(params) => (
<TextField {...params} label="Account Name" size="small" required />
)}
sx={{ "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/>
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" /> <TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
<Box sx={{ display: "flex", gap: 2 }}> <Box sx={{ display: "flex", gap: 2 }}>
<TextField <TextField
label="Start Date" label="Start Date"
type="datetime-local" type="date"
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => setStartDate(e.target.value)}
size="small" size="small"
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
<TextField <TextField
label="End Date" label="End Date"
type="datetime-local" type="date"
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setEndDate(e.target.value)}
size="small" size="small"
InputLabelProps={{ shrink: true }} InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
</Box> </Box>
@@ -233,68 +334,185 @@ export default function FetchRequests() {
</Box> </Box>
</Paper> </Paper>
<Paper sx={{ borderRadius: 4 }} variant="outlined"> <Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Typography variant="subtitle1" fontWeight={600}> <FormControl size="small" sx={{ minWidth: 200 }}>
Fetch Requests <InputLabel>Status</InputLabel>
</Typography> <Select
multiple
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as string[])}
input={<OutlinedInput label="Status" />}
renderValue={(selected) => (selected as string[]).join(", ")}
>
{["pending", "processing", "paused", "raw_expenses_done", "enriched_done", "completed", "failed"].map((s) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Account"
value={accountFilter}
onChange={(e) => setAccountFilter(e.target.value)}
size="small"
sx={{ minWidth: 160 }}
/>
<ToggleButtonGroup
value={sourceFilter}
exclusive
onChange={(_, val) => val && setSourceFilter(val)}
size="small"
>
{sourceTypeOptions.map((opt) => (
<ToggleButton key={opt} value={opt}>
{opt === "all" ? "All" : opt === "file" ? "File" : "Email"}
</ToggleButton>
))}
</ToggleButtonGroup>
<Box sx={{ flex: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}> <IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon /> <RefreshIcon />
</IconButton> </IconButton>
</Box> </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>ID</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" }}>
{req.id.slice(0, 8)}...
</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> </Paper>
{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 component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Account</TableCell>
<TableCell>Source</TableCell>
<TableCell>Date Range</TableCell>
<TableCell>Status</TableCell>
<TableCell>Retries</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => (
<TableRow
key={req.id}
hover
onClick={() => navigate(`/fetch-requests/${req.id}`)}
sx={{ cursor: "pointer", "&:last-child td": { border: 0 } }}
>
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{shortId(req.fingerprint)}
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
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>{req.account_name}</TableCell>
<TableCell>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{formatDateRange((req as any).start_date, (req as any).end_date)}
</Typography>
</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Tooltip title={req.error_message || req.status.replace(/_/g, " ")}>
<Chip
icon={statusIcons[req.status] as any}
label={req.status.replace(/_/g, " ")}
color={statusColors[req.status]}
size="small"
/>
</Tooltip>
</Box>
</TableCell>
<TableCell>
{(req.retry_count ?? 0) > 0 ? (
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{req.retry_count}/{RETRY_MAX}
</Typography>
) : (
<Typography variant="body2" sx={{ fontSize: "0.8rem", color: "text.disabled" }}>
</Typography>
)}
</TableCell>
<TableCell sx={{ whiteSpace: "nowrap", fontSize: "0.8rem" }}>
{formatDate(req.created_at)}
</TableCell>
<TableCell align="right">
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{req.status === "paused" && (
<Tooltip title="Resolve ambiguities">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
navigate(`/fetch-requests/${req.id}`);
}}
>
<WarningAmberIcon fontSize="small" color="warning" />
</IconButton>
</Tooltip>
)}
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
<Tooltip title="Retry">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleRetry(req);
}}
>
<ReplayIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(req);
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Snackbar <Snackbar
open={!!snackbar} open={!!snackbar}
autoHideDuration={4000} autoHideDuration={4000}

View File

@@ -28,6 +28,7 @@ import {
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle"; import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { import {
useReportSnapshotsList, useReportSnapshotsList,
useCreateSnapshot, useCreateSnapshot,
@@ -41,8 +42,7 @@ function formatDate(iso: string) {
} }
export default function ReportSnapshots() { export default function ReportSnapshots() {
const [accounts, setAccounts] = React.useState(""); const [ignoreSelf, setIgnoreSelf] = React.useState(true);
const [ignoreSelf, setIgnoreSelf] = React.useState(false);
const [startDate, setStartDate] = React.useState(""); const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState(""); const [endDate, setEndDate] = React.useState("");
const [minAmount, setMinAmount] = React.useState(""); const [minAmount, setMinAmount] = React.useState("");
@@ -60,7 +60,6 @@ export default function ReportSnapshots() {
const handleCreate = async () => { const handleCreate = async () => {
try { try {
const result = await createMutation.mutateAsync({ const result = await createMutation.mutateAsync({
accounts: accounts.trim() ? accounts.split(",").map((s) => s.trim()).filter(Boolean) : null,
ignore_self: ignoreSelf || null, ignore_self: ignoreSelf || null,
start_date: startDate ? new Date(startDate).toISOString() : null, start_date: startDate ? new Date(startDate).toISOString() : null,
end_date: endDate ? new Date(endDate).toISOString() : null, end_date: endDate ? new Date(endDate).toISOString() : null,
@@ -81,7 +80,6 @@ export default function ReportSnapshots() {
}; };
const resetForm = () => { const resetForm = () => {
setAccounts("");
setIgnoreSelf(false); setIgnoreSelf(false);
setStartDate(""); setStartDate("");
setEndDate(""); setEndDate("");
@@ -112,14 +110,6 @@ export default function ReportSnapshots() {
</Typography> </Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
label="Accounts"
value={accounts}
onChange={(e) => setAccounts(e.target.value)}
size="small"
helperText="Comma-separated account IDs (leave empty for all)"
/>
<FormControlLabel <FormControlLabel
control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />} control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
label="Ignore self-transfers" label="Ignore self-transfers"
@@ -215,7 +205,19 @@ export default function ReportSnapshots() {
{snapshots.map((snap: ReportSnapshot) => ( {snapshots.map((snap: ReportSnapshot) => (
<TableRow key={snap.id}> <TableRow key={snap.id}>
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}> <TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{snap.snapshot_id.slice(0, 12)}... <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>
<TableCell>{formatDate(snap.created_at)}</TableCell> <TableCell>{formatDate(snap.created_at)}</TableCell>
<TableCell> <TableCell>

View File

@@ -1,8 +1,20 @@
export type FetchRequestStatus = "pending" | "processing" | "raw_expenses_done" | "enriched_done" | "completed" | "failed"; export type FetchRequestStatus =
| "pending"
| "processing"
| "paused"
| "raw_expenses_done"
| "enriched_done"
| "completed"
| "failed";
export interface FileSource { export interface FileSource {
path: string; path: string;
format: string; format: string;
raw_lines?: string[];
txn_blocks?: Record<string, any>;
txn_dicts?: Record<string, any>[];
txn_dict_count?: number;
txn_dicts_count?: number;
} }
export interface EmailSource { export interface EmailSource {
@@ -10,6 +22,8 @@ export interface EmailSource {
from_email?: string; from_email?: string;
subject?: string; subject?: string;
raw_terms?: string[]; raw_terms?: string[];
txn_dict_count?: number;
txn_dicts_count?: number;
} }
export interface FetchRequestCreate { export interface FetchRequestCreate {
@@ -20,12 +34,18 @@ export interface FetchRequestCreate {
end_date?: string; end_date?: string;
} }
export interface FetchRequestUpdate {
status?: FetchRequestStatus;
error_message?: string | null;
}
export interface FetchRequest extends FetchRequestCreate { export interface FetchRequest extends FetchRequestCreate {
id: string; id: string;
status: FetchRequestStatus; status: FetchRequestStatus;
fingerprint: string; fingerprint: string;
completed_at?: string | null; completed_at?: string | null;
error_message?: string | null; error_message?: string | null;
retry_count?: number;
created_at: string; created_at: string;
} }
@@ -36,3 +56,61 @@ export interface UploadResult {
url: string; url: string;
absolute_path: string; absolute_path: string;
} }
export interface AmbiguityCandidate {
amount: number;
balance: number;
}
export interface PendingAmbiguity {
id: string;
fetch_request: string;
step_index?: number;
line: string;
ocr_amount: number;
ocr_balance: number;
prev_balance: number;
candidates: AmbiguityCandidate[];
chosen?: AmbiguityCandidate | null;
resolved_at?: string | null;
status: "pending" | "resolved";
created_at: string;
}
export interface ResolveAmbiguityPayload {
chosen: {
amount: number;
balance: number;
};
}
export type SSEEventStep =
| "load_content" | "raw_lines" | "txn_blocks" | "txn_dicts"
| "resume_extract" | "extract" | "paused" | "complete" | "enrich"
| "save_expenses" | "pipeline";
export type SSEEventStatus =
| "started" | "completed" | "skipped" | "paused" | "progress" | "failed";
export interface ProgressMessage {
lines?: number;
blocks?: number;
count?: number;
unit?: string;
raw_ocr_line?: string;
error?: string;
}
export interface SSEEvent {
step: SSEEventStep;
status: SSEEventStatus;
message: ProgressMessage;
}
export interface FetchRequestFilters {
status?: FetchRequestStatus[];
account_name?: string;
source_type?: "file" | "email";
}
export const RETRY_MAX = 3;

View File

@@ -1,15 +1,28 @@
export type { export type {
FetchRequest, FetchRequest,
FetchRequestCreate, FetchRequestCreate,
FetchRequestUpdate,
FetchRequestStatus, FetchRequestStatus,
FetchRequestFilters,
FileSource, FileSource,
EmailSource, EmailSource,
UploadResult, UploadResult,
PendingAmbiguity,
AmbiguityCandidate,
ResolveAmbiguityPayload,
SSEEvent,
SSEEventStep,
SSEEventStatus,
ProgressMessage,
} from "./fetch-requests.models"; } from "./fetch-requests.models";
export { RETRY_MAX } from "./fetch-requests.models";
export { export {
useFetchRequestsList, useFetchRequestsList,
useFetchRequest, useFetchRequest,
useCreateFetchRequest, useCreateFetchRequest,
useUpdateFetchRequest,
useDeleteFetchRequest, useDeleteFetchRequest,
useUploadFile, useUploadFile,
useFetchRequestAmbiguities,
useResolveAmbiguity,
} from "./useFetchRequests"; } from "./useFetchRequests";

View File

@@ -1,6 +1,7 @@
import { useResourceByName } from "../../../react-openapi"; import { useResourceByName } from "../../../react-openapi";
import { api } from "../../../react-openapi/api/client"; import { api } from "../../../react-openapi/api/client";
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
export function useFetchRequestsList(params?: { export function useFetchRequestsList(params?: {
status?: string; status?: string;
@@ -21,6 +22,11 @@ export function useCreateFetchRequest() {
return useCreate(); return useCreate();
} }
export function useUpdateFetchRequest() {
const { usePatch } = useResourceByName("fetch-requests");
return usePatch();
}
export function useDeleteFetchRequest() { export function useDeleteFetchRequest() {
const { useDelete } = useResourceByName("fetch-requests"); const { useDelete } = useResourceByName("fetch-requests");
return useDelete(); return useDelete();
@@ -41,3 +47,44 @@ export function useUploadFile() {
}, },
}); });
} }
export function useFetchRequestAmbiguities(fetchRequestId: string) {
return useQuery({
queryKey: ["fetch-requests", fetchRequestId, "ambiguities"],
queryFn: async () => {
const res = await api.get(
`/fetch-requests/${fetchRequestId}/ambiguities`
);
return res.data;
},
enabled: !!fetchRequestId,
});
}
export function useResolveAmbiguity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
ambiguityId,
payload,
}: {
ambiguityId: string;
payload: ResolveAmbiguityPayload;
}) => {
const res = await api.post(
`/ambiguities/${ambiguityId}/resolve`,
payload
);
return res.data;
},
onSuccess: (data: any) => {
queryClient.invalidateQueries({
queryKey: ["fetch-requests", data.fetch_request, "ambiguities"],
});
queryClient.invalidateQueries({
queryKey: ["fetch-requests", "detail", data.fetch_request],
});
},
});
}

View File

@@ -13,6 +13,7 @@ import {
import Home from './Home'; import Home from './Home';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import FetchRequests from './FetchRequests'; import FetchRequests from './FetchRequests';
import FetchRequestDetail from './FetchRequestDetail';
import ReportSnapshots from './ReportSnapshots'; 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';
@@ -36,6 +37,7 @@ const routerMapping = [
{ 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: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" },
{ path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" },
{ path: "/reports", component: ReportSnapshots, headerTitle: "Reports" }, { path: "/reports", component: ReportSnapshots, headerTitle: "Reports" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" },
]; ];

View File

@@ -2,9 +2,14 @@ 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,
@@ -12,11 +17,14 @@ 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();
@@ -34,15 +42,14 @@ 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 = {