5 Commits

45 changed files with 391 additions and 3422 deletions

View File

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

14
package-lock.json generated
View File

@@ -28,7 +28,6 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest" "vite": "latest"
} }
}, },
@@ -4104,19 +4103,6 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View File

@@ -28,7 +28,6 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest" "vite": "latest"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -6,7 +6,6 @@ import ResourceView from "./components/ResourceView";
import { getAppConfig } from "./config"; import { getAppConfig } from "./config";
import { initializeApiClients } from "./api/client"; import { initializeApiClients } from "./api/client";
import { AppConfig } from "./types/config"; import { AppConfig } from "./types/config";
import { FieldComponents } from "./types/overrides";
import { Box, Typography, Paper, CircularProgress } from "@mui/material"; import { Box, Typography, Paper, CircularProgress } from "@mui/material";
import { import {
Routes, Routes,
@@ -16,9 +15,8 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import { ConfigContext } from "./providers/ConfigContext"; import { ConfigContext } from "./providers/ConfigContext";
import ProfileView from "./components/ProfileView";
function DefaultDashboard({ basePath }: { basePath: string }) { function Dashboard({ basePath }: { basePath: string }) {
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -33,6 +31,7 @@ function DefaultDashboard({ basePath }: { basePath: string }) {
<Typography variant="body1" sx={{ color: 'text.secondary' }}> <Typography variant="body1" sx={{ color: 'text.secondary' }}>
Select a resource from the sidebar to manage data. Select a resource from the sidebar to manage data.
</Typography> </Typography>
<Box <Box
sx={{ sx={{
display: "grid", display: "grid",
@@ -62,15 +61,9 @@ function DefaultDashboard({ basePath }: { basePath: string }) {
); );
} }
interface AdminAppProps { import ProfileView from "./components/ProfileView";
basePath: string;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
}
function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Layout = AdminLayout, LoginPage = AuthPage }: AdminAppProps) { function AdminApp({ basePath }: { basePath: string }) {
const { currentUser, login, logout, loading, error } = useAuth(); const { currentUser, login, logout, loading, error } = useAuth();
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -80,10 +73,10 @@ function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Lay
if (!currentUser) { if (!currentUser) {
return ( return (
<LoginPage <AuthPage
mode="login" mode="login"
login={login} login={login}
register={async () => {}} register={async () => {}} // Disable registration for Admin
loading={loading} loading={loading}
error={error} error={error}
onSwitchMode={() => {}} onSwitchMode={() => {}}
@@ -94,7 +87,7 @@ function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Lay
} }
return ( return (
<Layout <AdminLayout
username={currentUser.username} username={currentUser.username}
onLogout={logout} onLogout={logout}
onSelectResource={(name) => navigate(`/admin/${name}`)} onSelectResource={(name) => navigate(`/admin/${name}`)}
@@ -103,44 +96,32 @@ function AdminApp({ basePath, fieldComponents, Dashboard = DefaultDashboard, Lay
<Routes> <Routes>
<Route path="/" element={<Dashboard basePath={basePath} />} /> <Route path="/" element={<Dashboard basePath={basePath} />} />
<Route path="/profile" element={<ProfileView />} /> <Route path="/profile" element={<ProfileView />} />
<Route path="/:resourceName" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName/create" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper fieldComponents={fieldComponents} />} /> <Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
</Routes> </Routes>
</Layout> </AdminLayout>
); );
} }
function ResourceRouteWrapper({ fieldComponents }: { fieldComponents: FieldComponents }) { function ResourceRouteWrapper() {
const { resourceName } = useParams(); const { resourceName } = useParams();
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const selectedResource = config?.resources.find((r) => r.name === resourceName); const selectedResource = config?.resources.find((r) => r.name === resourceName);
if (!selectedResource) return <Typography>Resource not found</Typography>; if (!selectedResource) return <Typography>Resource not found</Typography>;
return <ResourceView config={selectedResource} fieldComponents={fieldComponents} />; return <ResourceView config={selectedResource} />;
}
interface AdminLayoutProps {
children: React.ReactNode;
onSelectResource: (resourceName: string | null) => void;
onLogout: () => void;
username?: string;
resources: import("./types/config").ResourceConfig[];
} }
interface AdminProps { interface AdminProps {
basePath?: string; basePath?: string;
resourceOverrides?: Record<string, any>; resourceOverrides?: Record<string, any>;
profileConfig?: any; profileConfig?: any;
fieldComponents: FieldComponents;
Dashboard?: React.ComponentType<{ basePath: string }>;
Layout?: React.ComponentType<AdminLayoutProps>;
LoginPage?: React.ComponentType<any>;
} }
export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {}, fieldComponents, Dashboard, Layout, LoginPage }: AdminProps) { export default function Admin({ basePath = "/admin", resourceOverrides = {}, profileConfig = {} }: AdminProps) {
const existingConfig = React.useContext(ConfigContext); const existingConfig = React.useContext(ConfigContext);
const [config, setConfig] = React.useState<AppConfig | null>(existingConfig); const [config, setConfig] = React.useState<AppConfig | null>(existingConfig);
@@ -170,14 +151,16 @@ export default function Admin({ basePath = "/admin", resourceOverrides = {}, pro
const content = ( const content = (
<UploadProvider> <UploadProvider>
<AdminApp basePath={basePath} fieldComponents={fieldComponents} Dashboard={Dashboard} Layout={Layout} LoginPage={LoginPage} /> <AdminApp basePath={basePath} />
</UploadProvider> </UploadProvider>
); );
// If we have an existing config, we are already inside a Provider and QueryClient
if (existingConfig) { if (existingConfig) {
return content; return content;
} }
// Fallback for standalone usage
return ( return (
<ConfigContext.Provider value={config}> <ConfigContext.Provider value={config}>
{content} {content}

View File

@@ -1,5 +1,4 @@
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import type { AxiosResponse } from "axios";
import { createApiClient } from "../../react-auth"; import { createApiClient } from "../../react-auth";
/** /**
@@ -31,25 +30,21 @@ function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
} }
export const api = { export const api = {
get: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["get"]>[1]) => { get: (...args: Parameters<AxiosInstance["get"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.get<T, R>(url, config); return _api.get(...args);
}, },
post: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["post"]>[2]) => { post: (...args: Parameters<AxiosInstance["post"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.post<T, R>(url, data, config); return _api.post(...args);
}, },
put: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["put"]>[2]) => { put: (...args: Parameters<AxiosInstance["put"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.put<T, R>(url, data, config); return _api.put(...args);
}, },
delete: <T = any, R = AxiosResponse<T>>(url: string, config?: Parameters<AxiosInstance["delete"]>[1]) => { delete: (...args: Parameters<AxiosInstance["delete"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
return _api.delete<T, R>(url, config); return _api.delete(...args);
},
patch: <T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: Parameters<AxiosInstance["patch"]>[2]) => {
if (!_api) throw new Error("API client not initialized");
return _api.patch<T, R>(url, data, config);
}, },
}; };

View File

@@ -31,8 +31,6 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import { EnhancedTableComponents } from '../types/overrides';
import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options';
interface EnhancedTableProps { interface EnhancedTableProps {
config: ResourceConfig; config: ResourceConfig;
@@ -45,34 +43,24 @@ interface EnhancedTableProps {
onDelete: (id: string) => void; onDelete: (id: string) => void;
onCreate: () => void; onCreate: () => void;
onNavigateToResource?: (resourceName: string, id: string) => void; onNavigateToResource?: (resourceName: string, id: string) => void;
components?: EnhancedTableComponents;
} }
export default function EnhancedTable({ export default function EnhancedTable({
config, config,
data, data,
total, total,
paginationModel: externalPaginationModel, paginationModel,
onPaginationModelChange: externalOnPaginationModelChange, onPaginationModelChange,
loading = false, loading = false,
onEdit, onEdit,
onDelete, onDelete,
onCreate, onCreate,
onNavigateToResource, onNavigateToResource,
components: tableComponents,
}: EnhancedTableProps) { }: EnhancedTableProps) {
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]) => {
let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string'; let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string';
@@ -88,7 +76,7 @@ export default function EnhancedTable({
type: muiType, type: muiType,
flex: 1, flex: 1,
minWidth: 150, minWidth: 150,
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} components={tableComponents} /> renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
}; };
if (muiType === 'date' || muiType === 'dateTime') { if (muiType === 'date' || muiType === 'dateTime') {
@@ -99,8 +87,9 @@ export default function EnhancedTable({
}; };
} }
if (muiType === 'singleSelect') { if (muiType === 'singleSelect' && field.options) {
(col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field)); // @ts-ignore
col.valueOptions = field.options;
} }
return col; return col;
@@ -133,15 +122,6 @@ export default function EnhancedTable({
return cols; return cols;
}, [config, onDelete, navigate, onNavigateToResource]); }, [config, onDelete, navigate, onNavigateToResource]);
const mobilePageSize = 10;
const [mobilePage, setMobilePage] = React.useState(0);
const mobileTotalPages = Math.ceil(data.length / mobilePageSize) || 1;
const mobileData = data.slice(mobilePage * mobilePageSize, (mobilePage + 1) * mobilePageSize);
React.useEffect(() => {
if (mobilePage >= mobileTotalPages) setMobilePage(0);
}, [data.length, mobilePage, mobileTotalPages]);
if (isMobile) { if (isMobile) {
return ( return (
<Box> <Box>
@@ -152,7 +132,7 @@ export default function EnhancedTable({
</Button> </Button>
</Box> </Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{mobileData.map((row) => ( {data.map((row) => (
<Box key={row[config.primaryKey] || Math.random()}> <Box key={row[config.primaryKey] || Math.random()}>
<MobileCardRow <MobileCardRow
row={row} row={row}
@@ -161,22 +141,10 @@ export default function EnhancedTable({
onDelete={onDelete} onDelete={onDelete}
onNavigate={onNavigateToResource} onNavigate={onNavigateToResource}
navigate={navigate} navigate={navigate}
components={tableComponents}
/> />
</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>
); );
} }
@@ -193,18 +161,20 @@ export default function EnhancedTable({
rows={data || []} rows={data || []}
columns={columns} columns={columns}
autoHeight autoHeight
paginationMode={isServer ? 'server' : 'client'} paginationMode={config.pagination ? 'server' : 'client'}
{...(isServer ? { rowCount={(() => {
rowCount: (() => { if (!config.pagination) return data.length;
if (total !== undefined) return total; if (total !== undefined) return total;
// Graceful fallback for missing total count
const page = paginationModel?.page || 0; const page = paginationModel?.page || 0;
const pageSize = paginationModel?.pageSize || 10; const pageSize = paginationModel?.pageSize || 10;
if (data.length < pageSize) { if (data.length < pageSize) {
return page * pageSize + data.length; return page * pageSize + data.length;
} }
// Enable 'Next' button by pretending there's at least one more page
return (page + 2) * pageSize; return (page + 2) * pageSize;
})(), })()}
} : {})}
loading={loading} loading={loading}
paginationModel={paginationModel || { page: 0, pageSize: 10 }} paginationModel={paginationModel || { page: 0, pageSize: 10 }}
onPaginationModelChange={onPaginationModelChange} onPaginationModelChange={onPaginationModelChange}
@@ -229,7 +199,7 @@ export default function EnhancedTable({
); );
} }
function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components }: any) { function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const id = row[config.primaryKey]; const id = row[config.primaryKey];
@@ -264,8 +234,8 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{field.label} {field.label}
</Typography> </Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}> <Typography variant="body2" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile components={components} /> <FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
</Typography> </Typography>
</Box> </Box>
))} ))}
@@ -278,9 +248,8 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate, components
); );
} }
function getFormattedDisplayValue(item: any, displayField?: string | string[], enumValue?: string) { function getFormattedDisplayValue(item: any, displayField?: string | string[]) {
if (!item) return ""; if (!item) return "";
if (enumValue) return resolveTemplate(enumValue, item);
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item); if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
if (Array.isArray(displayField)) { if (Array.isArray(displayField)) {
@@ -293,21 +262,16 @@ function getFormattedDisplayValue(item: any, displayField?: string | string[], e
return item[displayField] || item.id || JSON.stringify(item); return item[displayField] || item.id || JSON.stringify(item);
} }
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile, components }: any) { function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
const value = params.value; const value = params.value;
const isPk = fieldKey === config.primaryKey; const isPk = fieldKey === config.primaryKey;
if (field.formatter) return field.formatter(value); if (field.formatter) return field.formatter(value);
const customRenderer = components?.cellRenderers?.[field.type as string];
if (customRenderer) {
return React.createElement(customRenderer, { value, row: params.row, field, fieldKey, config, onNavigate, isMobile });
}
// 1. Single Relation // 1. Single Relation
if (field.relation && value && !Array.isArray(value)) { if (field.relation && value && !Array.isArray(value)) {
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value; const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
const displayValue = getFormattedDisplayValue(value, field.displayField, field.enumOption?.value); const displayValue = getFormattedDisplayValue(value, field.displayField);
return ( return (
<Chip <Chip
@@ -326,8 +290,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 2. Multi-Select (Array of relations or simple strings) // 2. Multi-Select (Array of relations or simple strings)
if (field.type === 'array' && Array.isArray(value)) { if (field.type === 'array' && Array.isArray(value)) {
const enumValue = field.enumOption?.value; const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).join(', ');
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField, enumValue)).join(', ');
return ( return (
<Tooltip title={tooltipTitle} arrow placement="top"> <Tooltip title={tooltipTitle} arrow placement="top">
@@ -335,7 +298,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
{value.map((item, idx) => ( {value.map((item, idx) => (
<Chip <Chip
key={idx} key={idx}
label={getFormattedDisplayValue(item, field.displayField, enumValue)} label={getFormattedDisplayValue(item, field.displayField)}
size="small" size="small"
variant="filled" variant="filled"
sx={{ maxWidth: 120 }} sx={{ maxWidth: 120 }}
@@ -355,7 +318,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 3. Simple Objects // 3. Simple Objects
if (field.type === 'object' && value) { if (field.type === 'object' && value) {
return getFormattedDisplayValue(value, field.displayField, field.enumOption?.value) || (isMobile ? 'Object' : JSON.stringify(value)); return getFormattedDisplayValue(value, field.displayField) || (isMobile ? 'Object' : JSON.stringify(value));
} }
if (field.type === 'number' && typeof value === 'number') { if (field.type === 'number' && typeof value === 'number') {
@@ -390,11 +353,6 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : ''; if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
if (field.type === 'enum') {
const opt = getFieldOptions(field).find(o => o.key === value);
return opt?.value ?? value;
}
if (isPk && !isMobile) { if (isPk && !isMobile) {
return ( return (
<Chip <Chip

View File

@@ -1,308 +0,0 @@
import * as React from "react";
import {
Box,
Button,
Chip,
Paper,
TextField,
Autocomplete,
Typography,
} from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import FilterListIcon from "@mui/icons-material/FilterList";
import { ResourceField, ResourceMode } from "../types/config";
import { FilterBarComponents, FieldComponents } from "../types/overrides";
import { getFieldOptions, resolveTemplate } from "../utils/options";
export 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.type === 'enum') {
return getFieldOptions(field).map(o => o.value);
}
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);
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
// Use displayFormat if defined, otherwise fall back to displayField logic (for backward compatibility)
if (field.displayFormat) {
return resolveTemplate(field.displayFormat, item);
}
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,
components?: FilterBarComponents,
fieldComponents?: FieldComponents,
) {
const filterType = field.filterType;
if (filterType === "number-range") {
const RangeComponent = fieldComponents?.numberRange;
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
const rangeVal = (value as { min?: string; max?: string }) || {};
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
}
if (filterType === "date-range") {
const RangeComponent = fieldComponents?.dateRange;
if (!RangeComponent) throw new Error(`Number range component not found for field ${fieldName}`);
const rangeVal = (value as { start?: string; end?: string }) || {};
return <RangeComponent name={fieldName} field={field} value={rangeVal} onChange={(val: any) => onChange("value", val)} />;
}
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;
components?: FilterBarComponents;
fieldComponents?: FieldComponents;
}
export default function FilterBar({
fields,
filterableFields,
data,
appliedValues,
onApply,
onClear,
components: filterComponents,
fieldComponents,
}: 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 === "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), filterComponents, fieldComponents
)}
</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

@@ -7,7 +7,6 @@ import {
CircularProgress, CircularProgress,
} from '@mui/material'; } from '@mui/material';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import { FieldComponents } from '../types/overrides';
import { useUpload } from '../providers/UploadProvider'; import { useUpload } from '../providers/UploadProvider';
import { useQueries } from '@tanstack/react-query'; import { useQueries } from '@tanstack/react-query';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
@@ -22,7 +21,6 @@ interface GenericFormProps {
loading?: boolean; loading?: boolean;
readOnly?: boolean; readOnly?: boolean;
onEditClick?: () => void; onEditClick?: () => void;
fieldComponents: FieldComponents;
} }
export default function GenericForm({ export default function GenericForm({
@@ -33,7 +31,6 @@ export default function GenericForm({
loading: saving, loading: saving,
readOnly = false, readOnly = false,
onEditClick, onEditClick,
fieldComponents,
}: GenericFormProps) { }: GenericFormProps) {
initialData = initialData || {}; initialData = initialData || {};
const [formData, setFormData] = React.useState(initialData); const [formData, setFormData] = React.useState(initialData);
@@ -57,7 +54,7 @@ export default function GenericForm({
queries: allRelations.map(relName => { queries: allRelations.map(relName => {
const relatedRes = appConfig?.resources.find(r => r.name === relName); const relatedRes = appConfig?.resources.find(r => r.name === relName);
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents }); const { getListQueryOptions } = useResource(relatedRes!);
return { return {
...getListQueryOptions(), ...getListQueryOptions(),
enabled: !!relatedRes, enabled: !!relatedRes,
@@ -120,7 +117,6 @@ export default function GenericForm({
uploading={uploading} uploading={uploading}
baseUrl={appConfig?.baseUrl || ""} baseUrl={appConfig?.baseUrl || ""}
relationDataMap={relationDataMap} relationDataMap={relationDataMap}
components={fieldComponents}
/> />
))} ))}

View File

@@ -3,7 +3,6 @@ import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import GenericForm from './GenericForm'; import GenericForm from './GenericForm';
import { ConfigContext } from '../providers/ConfigContext'; import { ConfigContext } from '../providers/ConfigContext';
import { defaultFieldComponents } from './fields/DefaultFieldComponents';
export default function ProfileView() { export default function ProfileView() {
const appConfig = React.useContext(ConfigContext); const appConfig = React.useContext(ConfigContext);
@@ -14,6 +13,7 @@ export default function ProfileView() {
return <Alert severity="error">Profile configuration not found.</Alert>; return <Alert severity="error">Profile configuration not found.</Alert>;
} }
// Create a modified config where only extraFields are editable
const editableConfig = React.useMemo(() => { const editableConfig = React.useMemo(() => {
const newFields = { ...resourceConfig.fields }; const newFields = { ...resourceConfig.fields };
const extraFields = profileConfig.extraFields || []; const extraFields = profileConfig.extraFields || [];
@@ -31,12 +31,13 @@ export default function ProfileView() {
}; };
}, [resourceConfig, profileConfig.extraFields]); }, [resourceConfig, profileConfig.extraFields]);
const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents }); const { useMe, useUpdateMe } = useResource(resourceConfig);
const { data: profile, isLoading, error } = useMe(); const { data: profile, isLoading, error } = useMe();
const updateMutation = useUpdateMe(); const updateMutation = useUpdateMe();
const handleSave = async (formData: any) => { const handleSave = async (formData: any) => {
try { try {
// Only send editable fields to prevent accidental overwrites of read-only data
const extraFields = profileConfig.extraFields || []; const extraFields = profileConfig.extraFields || [];
const dataToSave = Object.keys(formData) const dataToSave = Object.keys(formData)
.filter(key => extraFields.includes(key)) .filter(key => extraFields.includes(key))
@@ -75,7 +76,6 @@ export default function ProfileView() {
onSave={handleSave} onSave={handleSave}
onCancel={() => window.history.back()} onCancel={() => window.history.back()}
loading={updateMutation.isPending} loading={updateMutation.isPending}
fieldComponents={defaultFieldComponents}
/> />
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,103 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import { Box, Paper, CircularProgress } from '@mui/material'; import { Box, Typography, Paper, CircularProgress } from '@mui/material';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import type { ResourceField } from '../types/config';
import { FieldComponents } from '../types/overrides';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import { resolveTemplate } from '../utils/options'; import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable'; import EnhancedTable from './EnhancedTable';
import FilterBar from './FilterBar'; import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
interface ResourceViewProps { interface ResourceViewProps {
config: ResourceConfig; config: ResourceConfig;
onNavigateToResource?: (resourceName: string, id: string) => void; onNavigateToResource?: (resourceName: string, id: string) => void;
fieldComponents: FieldComponents;
} }
import { GridPaginationModel } from '@mui/x-data-grid'; import { GridPaginationModel } from '@mui/x-data-grid';
function getDisplayString(item: any, field: ResourceField): string { export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
if (item == null || typeof item !== 'object') return String(item ?? '');
if (field.enumOption?.value) return resolveTemplate(field.enumOption.value, item);
const df = field.displayField;
if (!df) return item.name ?? item.title ?? item.label ?? item.id ?? JSON.stringify(item);
if (Array.isArray(df)) {
const parts = df.map((k: string) => item[k]).filter((v: any) => v != null);
return parts.length > 0 ? parts.join(' ') : '';
}
return String(item[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) =>
filterValue.includes(getDisplayString(el, field))
);
}
if (itemValue && typeof itemValue === "object") {
return filterValue.includes(getDisplayString(itemValue, field));
}
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) =>
getDisplayString(el, field) === String(filterValue)
);
}
if (itemValue && typeof itemValue === "object") {
return getDisplayString(itemValue, field) === String(filterValue);
}
return String(itemValue) === String(filterValue);
})
);
}
export default function ResourceView({ config, onNavigateToResource, fieldComponents }: ResourceViewProps) {
const { id } = useParams(); const { id } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -107,36 +23,26 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
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, components } = useResource(config, { fieldComponents });
// Determine query parameters based on pagination config
const queryParams = React.useMemo(() => { const queryParams = React.useMemo(() => {
if (!isServer) return { limit: 10 }; if (!config.pagination) return {};
return { return {
skip: paginationModel.page * paginationModel.pageSize, skip: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize, limit: paginationModel.pageSize,
}; };
}, [isServer, paginationModel]); }, [config.pagination, paginationModel]);
const listQuery = useList(queryParams); const listQuery = useList(queryParams);
const itemQuery = useRead(id || ""); const itemQuery = useRead(id || "");
const rawData = listQuery.data?.data || []; const paginatedData = listQuery.data || { data: [], total: undefined };
const totalCount = listQuery.data?.total;
const filteredData = React.useMemo(
() => (isServer ? rawData : applyClientFilters(rawData, appliedFilters, config.fields)),
[isServer, rawData, appliedFilters, config.fields]
);
const createMutation = useCreate(); const createMutation = useCreate();
const updateMutation = useUpdate(); const updateMutation = useUpdate();
const deleteMutation = useDelete(); const deleteMutation = useDelete();
@@ -174,35 +80,21 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
return ( return (
<Box> <Box>
{isList ? ( {isList ? (
<Box>
{!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && (
<FilterBar
fields={config.fields}
filterableFields={config.filterOptions.fields}
mode={config.filterOptions?.mode || "server"}
data={rawData}
appliedValues={appliedFilters}
onApply={setAppliedFilters}
onClear={() => setAppliedFilters({})}
fieldComponents={components}
/>
)}
<EnhancedTable <EnhancedTable
config={config} config={config}
data={filteredData} data={paginatedData.data || []}
total={isServer ? totalCount : filteredData.length} total={paginatedData.total}
paginationModel={isServer ? paginationModel : undefined} paginationModel={paginationModel}
onPaginationModelChange={isServer ? setPaginationModel : undefined} onPaginationModelChange={setPaginationModel}
loading={listQuery.isFetching} loading={listQuery.isFetching}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onCreate={handleCreate} onCreate={handleCreate}
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)} onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
/> />
</Box>
) : ( ) : (
<Paper sx={{ p: 4 }}> <Paper sx={{ p: 4 }}>
{components && <components.GenericForm <GenericForm
config={config} config={config}
initialData={isCreate ? null : itemQuery.data} initialData={isCreate ? null : itemQuery.data}
onSave={handleSave} onSave={handleSave}
@@ -210,7 +102,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
loading={createMutation.isPending || updateMutation.isPending} loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView} readOnly={isView}
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)} onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
/>} />
</Paper> </Paper>
)} )}
</Box> </Box>

View File

@@ -1,17 +0,0 @@
import { FormControlLabel, Checkbox } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function BooleanField({ field, value, onChange, disabled }: FieldComponentProps) {
return (
<FormControlLabel
control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={field.label}
/>
);
}

View File

@@ -1,18 +0,0 @@
import { TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function DateField({ field, value, onChange, disabled }: FieldComponentProps) {
const isDatetime = field.type === 'datetime';
return (
<MuiTextField
fullWidth
label={field.label}
type={isDatetime ? "datetime-local" : "date"}
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().slice(0, isDatetime ? 16 : 10) : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -1,30 +0,0 @@
import { Box, TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function DateRangeField({ value, onChange, disabled }: FieldComponentProps) {
const rangeVal = (value as { start?: string; end?: string }) || {};
return (
<Box sx={{ display: "flex", gap: 1 }}>
<MuiTextField
type="date"
placeholder="From"
size="small"
value={rangeVal.start ?? ""}
onChange={(e) => onChange({ ...rangeVal, start: e.target.value || undefined })}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
disabled={disabled}
/>
<MuiTextField
type="date"
placeholder="To"
size="small"
value={rangeVal.end ?? ""}
onChange={(e) => onChange({ ...rangeVal, end: e.target.value || undefined })}
InputLabelProps={{ shrink: true }}
sx={{ width: 170 }}
disabled={disabled}
/>
</Box>
);
}

View File

@@ -1,40 +0,0 @@
import * as React from 'react';
import { FieldComponents, FieldComponentProps } from '../../types/overrides';
import TextFieldEntry from './TextField';
import NumberField from './NumberField';
import BooleanField from './BooleanField';
import DateField from './DateField';
import EnumField from './EnumField';
import RelationField from './RelationField';
import ImageUploadField from './ImageUploadField';
import FallbackField from './FallbackField';
import DateRangeField from './DateRangeField';
import NumberRangeField from './NumberRangeField';
const WrappedImageUploadField = (props: FieldComponentProps) =>
React.createElement(ImageUploadField, {
label: props.field.label,
value: props.value || '',
onUpload: async (file: File) => {
const url = await props.uploadFile?.(file);
if (url) props.onChange(url);
},
uploading: props.uploading,
baseUrl: props.baseUrl || '',
disabled: props.disabled,
});
export const defaultFieldComponents: FieldComponents = {
string: TextFieldEntry,
markdown: TextFieldEntry,
number: NumberField,
boolean: BooleanField,
date: DateField,
datetime: DateField,
enum: EnumField,
image: WrappedImageUploadField,
relation: RelationField,
default: FallbackField,
dateRange: DateRangeField,
numberRange: NumberRangeField,
};

View File

@@ -1,24 +0,0 @@
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
import { getFieldOptions } from '../../utils/options';
import { FieldComponentProps } from '../../types/overrides';
export default function EnumField({ field, value, onChange, disabled }: FieldComponentProps) {
const options = getFieldOptions(field);
return (
<FormControl fullWidth>
<InputLabel>{field.label}</InputLabel>
<Select
value={value || ''}
label={field.label}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{options.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>
{opt.value}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -1,13 +0,0 @@
import { TextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function FallbackField({ field, value }: FieldComponentProps) {
return (
<TextField
fullWidth
label={field.label}
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
disabled
/>
);
}

View File

@@ -1,19 +1,29 @@
import * as React from 'react'; import * as React from 'react';
import {
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Typography,
Box,
Divider,
} from '@mui/material';
import { ResourceField } from '../../types/config'; import { ResourceField } from '../../types/config';
import { FieldComponentProps, FieldComponents } from '../../types/overrides'; import ImageUploadField from './ImageUploadField';
import ObjectField from './ObjectField';
export interface FormFieldProps { interface FormFieldProps {
name: string; name: string;
field: ResourceField; field: ResourceField;
value: any; value: any;
onChange: (val: any) => void; onChange: (val: any) => void;
disabled?: boolean; disabled?: boolean;
uploadFile?: (file: File) => Promise<string | null>; uploadFile: (file: File) => Promise<string | null>;
uploading?: boolean; uploading: boolean;
baseUrl?: string; baseUrl: string;
relationDataMap?: Record<string, any[]>; relationDataMap?: Record<string, any[]>; // Map of relation name to data array
components: FieldComponents;
} }
export default function FormField({ export default function FormField({
@@ -26,60 +36,189 @@ export default function FormField({
uploading, uploading,
baseUrl, baseUrl,
relationDataMap = {}, relationDataMap = {},
components,
}: FormFieldProps) { }: FormFieldProps) {
const fieldProps: FieldComponentProps = { const label = field.label;
name,
field, // 1. Recursive Rendering for Objects (Not Relations)
value, if (field.type === 'object' && field.schema && !field.relation) {
onChange, return (
disabled, <Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
baseUrl, <Typography variant="subtitle2" color="primary" gutterBottom>
relationDataMap, {label}
uploadFile, </Typography>
uploading, <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Object.entries(field.schema).map(([subKey, subField]) => (
<FormField
key={subKey}
name={`${name}.${subKey}`}
field={subField}
value={value?.[subKey]}
onChange={(newVal) => {
const updated = { ...(value || {}), [subKey]: newVal };
onChange(updated);
}}
disabled={disabled}
uploadFile={uploadFile}
uploading={uploading}
baseUrl={baseUrl}
relationDataMap={relationDataMap}
/>
))}
</Box>
</Box>
);
}
// 2. Relation Handling (Select / Multi-Select)
if (field.relation && relationDataMap[field.relation]) {
const relationData = relationDataMap[field.relation].data;
const isArrayRelation = field.type === 'array';
// Determine how to display the related item
const getOptionLabel = (option: any) => {
if (!option) return "";
if (field.displayField && option[field.displayField]) return option[field.displayField];
// Standard naming fields
return option.name || option.title || option.label || option.id || JSON.stringify(option);
}; };
const childComponents = components; const getOptionValue = (option: any) => {
// Return the whole object to maintain identity
return option;
};
// 1. Object (recursive) - requires parent FormField for recursion return (
if (field.type === 'object' && field.schema && !field.relation) { <FormControl fullWidth>
const renderChild = (childProps: FieldComponentProps) => ( <InputLabel shrink>{label}</InputLabel>
<FormField <Select
name={childProps.name} multiple={isArrayRelation}
field={childProps.field} value={value || (isArrayRelation ? [] : "")}
value={childProps.value} label={label}
onChange={childProps.onChange} displayEmpty
disabled={childProps.disabled} onChange={(e) => onChange(e.target.value)}
uploadFile={childProps.uploadFile} disabled={disabled}
uploading={childProps.uploading} renderValue={(selected: any) => {
baseUrl={childProps.baseUrl} if (isArrayRelation) {
relationDataMap={childProps.relationDataMap} return (selected as any[]).map(getOptionLabel).join(', ');
components={components} }
return getOptionLabel(selected);
}}
>
{relationData.map((option) => (
<MenuItem key={option.id || JSON.stringify(option)} value={getOptionValue(option)}>
{getOptionLabel(option)}
</MenuItem>
))}
</Select>
</FormControl>
);
}
// 3. Image Handling
if (field.type === 'image') {
return (
<ImageUploadField
label={label}
value={value}
onUpload={async (file: any) => {
const url = await uploadFile(file);
if (url) onChange(url);
}}
uploading={uploading}
baseUrl={baseUrl}
disabled={disabled}
/> />
); );
return <ObjectField {...fieldProps} renderField={renderChild} />;
} }
// 2. Image // 4. Boolean Handling
if (field.type === 'image') { if (field.type === 'boolean') {
const ImageField = components.image; return (
if (!ImageField) return null; <FormControlLabel
return <ImageField {...fieldProps} />; control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={label}
/>
);
} }
// 3. Relation // 5. Enum Handling
if (field.relation && relationDataMap[field.relation]) { if (field.type === 'enum' && field.options) {
const RelationFieldComp = components.relation; return (
if (!RelationFieldComp) return null; <FormControl fullWidth>
return <RelationFieldComp {...fieldProps} />; <InputLabel>{label}</InputLabel>
<Select
value={value || ''}
label={label}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{field.options.map((opt: string) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
</FormControl>
);
} }
// 4. Lookup by field type // 6. Common Text Fields
const Component = components[field.type] || components.default; if (field.type === 'datetime' || field.type === 'date') {
if (Component) { return (
return <Component {...fieldProps} />; <TextField
fullWidth
label={label}
type={field.type === 'datetime' ? "datetime-local" : "date"}
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().slice(0, field.type === 'datetime' ? 16 : 10) : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
} }
return null; if (field.type === 'markdown' || field.type === 'string') {
return (
<TextField
fullWidth
label={label}
value={value || ''}
multiline={field.type === 'markdown'}
rows={field.type === 'markdown' ? 4 : 1}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'number') {
return (
<TextField
fullWidth
label={label}
type="number"
value={value === undefined || value === null ? '' : value}
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
disabled={disabled}
required={field.required}
/>
);
}
return (
<TextField
fullWidth
label={label}
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
disabled
/>
);
} }

View File

@@ -1,16 +0,0 @@
import { TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function NumberField({ field, value, onChange, disabled }: FieldComponentProps) {
return (
<MuiTextField
fullWidth
label={field.label}
type="number"
value={value === undefined || value === null ? '' : value}
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -1,28 +0,0 @@
import { Box, TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function NumberRangeField({ value, onChange, disabled }: FieldComponentProps) {
const rangeVal = (value as { min?: string; max?: string }) || {};
return (
<Box sx={{ display: "flex", gap: 1 }}>
<MuiTextField
type="number"
placeholder="Min"
size="small"
value={rangeVal.min ?? ""}
onChange={(e) => onChange({ ...rangeVal, min: e.target.value || undefined })}
sx={{ width: 100 }}
disabled={disabled}
/>
<MuiTextField
type="number"
placeholder="Max"
size="small"
value={rangeVal.max ?? ""}
onChange={(e) => onChange({ ...rangeVal, max: e.target.value || undefined })}
sx={{ width: 100 }}
disabled={disabled}
/>
</Box>
);
}

View File

@@ -1,36 +0,0 @@
import { Box, Typography } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export interface ObjectFieldProps extends FieldComponentProps {
renderField: (props: FieldComponentProps) => React.ReactNode;
}
export default function ObjectField({ name, field, value, onChange, disabled, baseUrl, uploadFile, uploading, relationDataMap, renderField }: ObjectFieldProps) {
if (!field.schema) return null;
return (
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
{field.label}
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Object.entries(field.schema).map(([subKey, subField]) =>
renderField({
name: `${name}.${subKey}`,
field: subField,
value: value?.[subKey],
onChange: (newVal: any) => {
const updated = { ...(value || {}), [subKey]: newVal };
onChange(updated);
},
disabled,
baseUrl,
uploadFile,
uploading,
relationDataMap,
})
)}
</Box>
</Box>
);
}

View File

@@ -1,50 +0,0 @@
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
import { getFieldOptions } from '../../utils/options';
import { FieldComponentProps } from '../../types/overrides';
export default function RelationField({ field, value, onChange, disabled, relationDataMap = {} }: FieldComponentProps) {
if (!field.relation || !relationDataMap[field.relation]) {
return null;
}
const relationData = relationDataMap[field.relation];
const isArrayRelation = field.type === 'array';
const options = getFieldOptions(field, relationData);
const keyField = field.enumOption?.key ?? 'id';
const normalizedValue = (() => {
if (isArrayRelation && Array.isArray(value)) {
return value.map((v: any) => (v != null && typeof v === 'object' ? String(v[keyField] ?? '') : String(v)));
}
if (value != null && typeof value === 'object') {
return String(value[keyField] ?? '');
}
return value ?? (isArrayRelation ? [] : "");
})();
return (
<FormControl fullWidth>
<InputLabel shrink>{field.label}</InputLabel>
<Select
multiple={isArrayRelation}
value={normalizedValue}
label={field.label}
displayEmpty
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
renderValue={(selected: any) => {
if (isArrayRelation) {
return (selected as string[]).map(k => options.find(o => o.key === k)?.value ?? k).join(', ');
}
return options.find(o => o.key === selected)?.value ?? selected;
}}
>
{options.map((opt) => (
<MenuItem key={opt.key} value={opt.key}>
{opt.value}
</MenuItem>
))}
</Select>
</FormControl>
);
}

View File

@@ -1,18 +0,0 @@
import { TextField as MuiTextField } from '@mui/material';
import { FieldComponentProps } from '../../types/overrides';
export default function TextField({ field, value, onChange, disabled }: FieldComponentProps) {
const isMarkdown = field.type === 'markdown';
return (
<MuiTextField
fullWidth
label={field.label}
value={value || ''}
multiline={isMarkdown}
rows={isMarkdown ? 4 : 1}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}

View File

@@ -1,14 +0,0 @@
export { default as FormField } from './FormField';
export { default as ImageUploadField } from './ImageUploadField';
export { default as TextField } from './TextField';
export { default as NumberField } from './NumberField';
export { default as BooleanField } from './BooleanField';
export { default as DateField } from './DateField';
export { default as EnumField } from './EnumField';
export { default as RelationField } from './RelationField';
export { default as ObjectField } from './ObjectField';
export { default as FallbackField } from './FallbackField';
export { default as DateRangeField } from './DateRangeField';
export { default as NumberRangeField } from './NumberRangeField';
export { defaultFieldComponents } from './DefaultFieldComponents';
export type { ObjectFieldProps } from './ObjectField';

View File

@@ -1,39 +1,23 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import * as React from "react";
import { api } from "../api/client"; import { api } from "../api/client";
import { ResourceConfig } from "../types/config"; import { ResourceConfig } from "../types/config";
import { ConfigContext } from "../providers/ConfigContext"; import { ConfigContext } from "../providers/ConfigContext";
import { FieldComponents, FieldComponentProps } from "../types/overrides"; import * as React from "react";
import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
import FormField from "../components/fields/FormField";
import GenericForm from "../components/GenericForm";
function wrapFormField(merged: FieldComponents) { export function useResource<T = any>(config: ResourceConfig | undefined) {
return (props: Omit<React.ComponentProps<typeof FormField>, 'components'>) =>
React.createElement(FormField, { ...props, components: merged });
}
function wrapGenericForm(merged: FieldComponents) {
return (props: Omit<React.ComponentProps<typeof GenericForm>, 'fieldComponents'>) =>
React.createElement(GenericForm, { ...props, fieldComponents: merged });
}
export function useResource<T = any>(config: ResourceConfig | undefined, options?: { fieldComponents: FieldComponents }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Return empty/disabled hooks if config is missing
const { name = '', endpoint = '', primaryKey = 'id' } = config || {}; const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
const mergedComponents = React.useMemo(
() => options?.fieldComponents ? ({ ...defaultFieldComponents, ...options.fieldComponents }) : undefined,
[options?.fieldComponents],
);
// --- READ ALL --- // --- READ ALL ---
const useList = (params?: any) => const useList = (params?: any) =>
useQuery({ useQuery({
queryKey: [name, "list", params], queryKey: [name, "list", params],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return { data: [], total: 0 }; if (!endpoint) return { data: [], total: 0 };
console.log('params:', params);
// @ts-ignore
const res = await api.get<T[]>(endpoint, { params }); const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
return { return {
@@ -46,12 +30,13 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
}); });
// --- READ ONE --- // --- READ ONE ---
const useRead = (id: string, params?: any | null) => const useRead = (id: string | null) =>
useQuery({ useQuery({
queryKey: [name, "detail", id, params], queryKey: [name, "detail", id],
queryFn: async () => { queryFn: async () => {
if (!id || !endpoint) return null; if (!id || !endpoint) return null;
const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined); // @ts-ignore
const res = await api.get<T>(`${endpoint}/${id}`);
return res.data; return res.data;
}, },
enabled: !!id && !!endpoint, enabled: !!id && !!endpoint,
@@ -62,6 +47,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async (data: Partial<T>) => { mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.post<T>(endpoint, data); const res = await api.post<T>(endpoint, data);
return res.data; return res.data;
}, },
@@ -75,31 +61,18 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => { mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.put<T>(`${endpoint}/${id}`, data); const res = await api.put<T>(`${endpoint}/${id}`, data);
return res.data; return res.data;
}, },
onSuccess: (updatedItem: any) => { onSuccess: (updatedItem) => {
// @ts-ignore
const id = updatedItem[primaryKey]; const id = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] }); queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", id] }); queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
}, },
}); });
// --- PATCH ---
const usePatch = () =>
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined");
const res = await api.patch<T>(`${endpoint}/${id}`, data);
return res.data;
},
onSuccess: (updatedItem: any) => {
const listId = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", listId] });
},
});
// --- DELETE --- // --- DELETE ---
const useDelete = () => const useDelete = () =>
useMutation({ useMutation({
@@ -118,6 +91,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "list", params], queryKey: [name, "list", params],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return { data: [], total: 0 }; if (!endpoint) return { data: [], total: 0 };
// @ts-ignore
const res = await api.get<T[]>(endpoint, { params }); const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined; const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
return { return {
@@ -134,6 +108,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "me"], queryKey: [name, "me"],
queryFn: async () => { queryFn: async () => {
if (!endpoint) return null; if (!endpoint) return null;
// @ts-ignore
const res = await api.get<T>(`${endpoint}/me`); const res = await api.get<T>(`${endpoint}/me`);
return res.data; return res.data;
}, },
@@ -145,6 +120,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({ useMutation({
mutationFn: async (data: Partial<T>) => { mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined"); if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.put<T>(`${endpoint}/me`, data); const res = await api.put<T>(`${endpoint}/me`, data);
return res.data; return res.data;
}, },
@@ -154,31 +130,21 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
}, },
}); });
const components = React.useMemo(() => {
if (!mergedComponents) return undefined;
return {
...mergedComponents,
FormField: wrapFormField(mergedComponents),
GenericForm: wrapGenericForm(mergedComponents),
};
}, [mergedComponents]);
return { return {
useList, useList,
useRead, useRead,
useMe, useMe,
useCreate, useCreate,
useUpdate, useUpdate,
usePatch,
useUpdateMe, useUpdateMe,
useDelete, useDelete,
getListQueryOptions, getListQueryOptions,
components,
}; };
} }
export function useResourceByName<T = any>(name: string, options?: { fieldComponents: FieldComponents }) { export function useResourceByName<T = any>(name: string) {
const config = React.useContext(ConfigContext); const config = React.useContext(ConfigContext);
const resourceConfig = config?.resources.find((r) => r.name === name); const resourceConfig = config?.resources.find((r) => r.name === name);
return useResource<T>(resourceConfig, options); return useResource<T>(resourceConfig);
} }

View File

@@ -1,13 +1,7 @@
export { default as Admin } from "./Admin"; export { default as Admin } from "./Admin";
export { api, auth, initializeApiClients } from "./api/client"; export { api, auth, initializeApiClients } from "./api/client";
export { getAppConfig } from "./config"; export { getAppConfig } from "./config";
export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config"; export type { AppConfig, ResourceConfig, ResourceField } from "./types/config";
export type { FieldComponents, FieldComponentProps, FieldComponent, FieldOverride, ResourceOverride, EnhancedTableComponents, FilterBarComponents, CellRendererProps, CellRenderer } from "./types/overrides";
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, FilterAutocomplete } from "./components/FilterBar";
export { default as EnhancedTable } from "./components/EnhancedTable";
export { default as GenericForm } from "./components/GenericForm";
export { default as ResourceView } from "./components/ResourceView";
export { defaultFieldComponents, FormField, TextField, NumberField, BooleanField, DateField, EnumField, RelationField, ObjectField, ImageUploadField, FallbackField } from "./components/fields";

View File

@@ -10,33 +10,18 @@ export type FieldType =
| 'object' | 'object'
| 'array'; | 'array';
export interface SelectOption {
key: string;
value: string;
}
export interface EnumOption {
key: string;
value: string;
}
export interface ResourceField { export interface ResourceField {
displayFormat: string;
type: FieldType; type: FieldType;
label: string; label: string;
required?: boolean; required?: boolean;
options?: string[]; options?: string[];
readOnly?: boolean; readOnly?: boolean;
schema?: Record<string, ResourceField>; schema?: Record<string, ResourceField>;
displayField?: string | string[];
formatter?: (value: any) => string; formatter?: (value: any) => string;
relation?: string; relation?: string; // Name of the target resource
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumOption?: EnumOption;
enumLabels?: Record<string, string>;
} }
export type ResourceMode = "server" | "client";
export interface ResourceConfig { export interface ResourceConfig {
name: string; name: string;
label: string; label: string;
@@ -46,18 +31,12 @@ export interface ResourceConfig {
fields: Record<string, ResourceField>; fields: Record<string, ResourceField>;
pagination?: boolean; pagination?: boolean;
hidden?: boolean; hidden?: boolean;
filterOptions?: {
mode?: ResourceMode;
fields?: string[];
};
enumOption?: EnumOption;
} }
export interface AppConfig { export interface AppConfig {
baseUrl: string; baseUrl: string;
authBaseUrl: string; authBaseUrl: string;
resources: ResourceConfig[]; resources: ResourceConfig[];
enums: Record<string, string[]>;
profile?: { profile?: {
resource: string; resource: string;
extraFields?: Record<string, any>; extraFields?: Record<string, any>;

View File

@@ -1,86 +1,16 @@
import { ResourceField, FieldType } from './config'; /**
* This file contains application-specific overrides and configuration
export interface EnumOption { * for the generic Admin Panel.
key: string; */
value: string;
}
export interface FieldOverride { export interface FieldOverride {
displayFormat?: string; displayField?: string | string[];
display?: boolean; display?: boolean;
formatter?: (value: any) => string; formatter?: (value: any) => string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumLabels?: Record<string, string>;
// New optional properties to support custom config extensions
path?: string;
refers?: string;
} }
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[];
};
enumOption?: EnumOption;
// New optional property for referencetype resources
referenceOptions?: {
enumOption?: EnumOption;
autoComplete?: boolean;
prefetch?: boolean;
};
} }
export interface FieldComponentProps {
name: string;
field: ResourceField;
value: any;
onChange: (val: any) => void;
disabled?: boolean;
error?: string;
baseUrl?: string;
relationDataMap?: Record<string, any[]>;
uploadFile?: (file: File) => Promise<string | null>;
uploading?: boolean;
}
export type FieldComponent = React.ComponentType<FieldComponentProps>;
export type FieldComponents = Partial<Record<FieldType, FieldComponent>> & {
relation?: FieldComponent;
image?: FieldComponent;
default?: FieldComponent;
dateRange?: FieldComponent;
numberRange?: FieldComponent;
FormField?: React.ComponentType<any>;
GenericForm?: React.ComponentType<any>;
};
export interface CellRendererProps {
value: any;
row: any;
field: ResourceField;
fieldKey: string;
config: import('./config').ResourceConfig;
onNavigate?: (resourceName: string, id: string) => void;
isMobile?: boolean;
}
export type CellRenderer = React.ComponentType<CellRendererProps>;
export interface EnhancedTableComponents {
cellRenderers?: Partial<Record<FieldType, CellRenderer>>;
}
export interface FilterBarComponents {
filterInputs?: Record<string, React.ComponentType<{
field: ResourceField;
value: any;
onChange: (val: any) => void;
options: string[];
}>>;
}
export type { FieldType };

View File

@@ -36,26 +36,6 @@ function mapOpenApiType(prop: any): FieldType {
/** /**
* Recursively converts OpenAPI schemas to ResourceField map * Recursively converts OpenAPI schemas to ResourceField map
*/ */
function mergeProperties(schema: any): { properties: Record<string, any>; required: string[] } {
let properties: Record<string, any> = {};
let required: string[] = [];
if (schema.allOf) {
for (const sub of schema.allOf) {
const merged = mergeProperties(sub);
properties = { ...properties, ...merged.properties };
required = [...required, ...merged.required];
}
}
if (schema.properties) {
properties = { ...properties, ...schema.properties };
}
if (schema.required) {
required = [...required, ...schema.required];
}
return { properties, required };
}
function parseSchemaFields( function parseSchemaFields(
schema: any, schema: any,
resourceName: string, resourceName: string,
@@ -63,26 +43,12 @@ function parseSchemaFields(
configuration: Record<string, any> = {} configuration: Record<string, any> = {}
): Record<string, ResourceField> { ): Record<string, ResourceField> {
const fields: Record<string, ResourceField> = {}; const fields: Record<string, ResourceField> = {};
const { properties, required } = mergeProperties(schema); const properties = schema.properties || {};
const required = schema.required || [];
const overrides = configuration[resourceName]?.fields || {}; const overrides = configuration[resourceName]?.fields || {};
console.log('inside parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
for (const [key, prop] of Object.entries(properties) as [string, any]) { for (const [key, prop] of Object.entries(properties) as [string, any]) {
// Resolve oneOf/anyOf by merging all branch properties const type = mapOpenApiType(prop);
let resolvedProp = prop;
if (prop.oneOf || prop.anyOf) {
const branches = prop.oneOf || prop.anyOf;
const merged = mergeProperties({ allOf: branches });
resolvedProp = { ...prop, type: 'object', properties: merged.properties, required: merged.required };
}
const type = mapOpenApiType(resolvedProp);
if (type === 'enum' && (!resolvedProp.enum || resolvedProp.enum.length === 0)) {
throw new Error(
`OpenAPI schema error: field "${resourceName}.${key}" is type "enum" but has no enum values. ` +
`Add an "enum" array with at least one value to the OpenAPI schema definition.`
);
}
const override = overrides[key]; const override = overrides[key];
// Explicitly skip 'id' as it's the primary key and handled elsewhere // Explicitly skip 'id' as it's the primary key and handled elsewhere
@@ -91,12 +57,12 @@ function parseSchemaFields(
fields[key] = { fields[key] = {
type, type,
label: label:
resolvedProp.title || prop.title ||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "), key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
required: required.includes(key), required: required.includes(key),
options: resolvedProp.enum, options: prop.enum,
readOnly: readOnly:
resolvedProp.readOnly || prop.readOnly ||
key === "created_at" || key === "created_at" ||
key === "updated_at", key === "updated_at",
...override, ...override,
@@ -105,36 +71,20 @@ function parseSchemaFields(
// STRICT RELATION DETECTION // STRICT RELATION DETECTION
// A field is a relation ONLY if its schema object (or items schema) // A field is a relation ONLY if its schema object (or items schema)
// exactly matches a schema that is defined as a resource. // exactly matches a schema that is defined as a resource.
let targetSchema = resolvedProp; let targetSchema = prop;
if (type === "array" && resolvedProp.items) { if (type === "array" && prop.items) {
targetSchema = resolvedProp.items; targetSchema = prop.items;
} }
// Check if this schema object is registered as a resource // Check if this schema object is registered as a resource
const relation = schemaToResourceMap.get(targetSchema); const relation = schemaToResourceMap.get(targetSchema);
if (relation) { if (relation) {
fields[key].relation = relation; fields[key].relation = relation;
// Propagate enumOption from target resource config, or derive from target schema
const explicitEnumOption = configuration[relation].referenceOptions.enumOption;
console.log('if relation configuration...', configuration['accounts']['referenceOptions'])
if (explicitEnumOption) {
fields[key].enumOption = explicitEnumOption;
} else {
// No explicit enumOption supplied this is a configuration error.
// We abort loading so the problem is visible immediately.
throw new Error(
`Missing enumOption for relation "${relation}" on field "${key}". ` +
`Define referenceOptions.enumOption in the configuration for resource "${relation}".`
);
}
} }
// Recursively parse nested objects (only if not a relation) // Recursively parse nested objects (only if not a relation)
if (fields[key].type === "object" && resolvedProp.properties && !relation) { if (fields[key].type === "object" && prop.properties && !relation) {
console.log('recursive configuration...', configuration['accounts']['referenceOptions']) fields[key].schema = parseSchemaFields(prop, resourceName, schemaToResourceMap, configuration);
fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration);
} }
} }
@@ -145,7 +95,6 @@ function parseSchemaFields(
* Scans paths to identify resources and their basic configuration * Scans paths to identify resources and their basic configuration
*/ */
export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> { export async function loadConfigFromOpenApi(baseUrl: string, configuration: Record<string, any> = {}, profileConfiguration: any = {}): Promise<AppConfig> {
console.log('init configuration...', configuration['accounts']['referenceOptions'])
// Use SwaggerParser to dereference the spec. // Use SwaggerParser to dereference the spec.
// Dereferencing preserves object identity for $ref targets. // Dereferencing preserves object identity for $ref targets.
const api = await SwaggerParser.dereference( const api = await SwaggerParser.dereference(
@@ -201,40 +150,22 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
const label = name.charAt(0).toUpperCase() + name.slice(1, -1); const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1); const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
console.log('before parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration); const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration);
const resourceOverride = configuration[name] || {}; const resourceOverride = configuration[name] || {};
const fo = resourceOverride.filterOptions || {};
resources.push({ resources.push({
name, name,
label: schema.title || label, label: schema.title || label,
pluralLabel: pluralLabel, pluralLabel: pluralLabel,
endpoint: listPath, endpoint: listPath,
primaryKey: "id", primaryKey: "id", // Strict default, no heuristics
fields, fields,
pagination: resourceOverride.pagination, pagination: resourceOverride.pagination,
hidden: resourceOverride.hidden, hidden: resourceOverride.hidden,
filterOptions: {
mode: fo.mode || "server",
fields: fo.fields,
},
}); });
} }
// Collect standalone enum schemas (e.g. FetchRequestStatus, AccountType, etc.)
const enums: Record<string, string[]> = {};
const apiDoc = api as any;
if (apiDoc.components?.schemas) {
for (const [name, schema] of Object.entries(apiDoc.components.schemas) as [string, any]) {
if (schema.enum) {
enums[name] = schema.enum;
}
}
}
// @ts-ignore // @ts-ignore
const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "") const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "")
// @ts-ignore // @ts-ignore
@@ -243,7 +174,6 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
baseUrl: serverBaseUrl, baseUrl: serverBaseUrl,
authBaseUrl: authBaseUrl, authBaseUrl: authBaseUrl,
resources, resources,
enums,
profile: profileConfiguration, profile: profileConfiguration,
}; };
} }

View File

@@ -1,39 +0,0 @@
import { ResourceField, SelectOption } from "../types/config";
export function resolveTemplate(template: string, item: any): string {
if (/\{(\w+)\}/.test(template)) {
return template.replace(/\{(\w+)\}/g, (_, field: string) => String(item[field] ?? ''));
}
return String(item[template] ?? '');
}
export function getFieldOptions(field: ResourceField, relationData?: any[]): SelectOption[] {
if (field.type === 'enum') {
return (field.options ?? []).map(opt => ({
key: opt,
value: field.enumLabels?.[opt] ?? opt,
}));
}
if (field.relation) {
const data = relationData ?? [];
const enumOption = field.enumOption;
if (!enumOption) {
throw new Error(
`Missing enumOption for relation "${field.relation}" on field "${field}". ` +
`Define referenceOptions.enumOption in the configuration for resource "${field.relation}".`
);
}
return data.map(item => ({
key: String(item[enumOption.key] ?? ''),
value: resolveTemplate(enumOption.value, item),
}));
}
return [];
}
export function toGridValueOptions(options: SelectOption[]): { value: string; label: string }[] {
return options.map(opt => ({ value: opt.key, label: opt.value }));
}

View File

@@ -23,18 +23,6 @@ import {
useReport, useReport,
prepareReport, prepareReport,
} from "./features/report"; } from "./features/report";
import { useResourceByName } from "../react-openapi";
function formatSnapshotDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function Dashboard() { export default function Dashboard() {
const [state, setState] = React.useState<DashboardState>({ const [state, setState] = React.useState<DashboardState>({
@@ -54,28 +42,7 @@ export default function Dashboard() {
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]); const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
const [loadedTags, setLoadedTags] = React.useState<string[]>([]); const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null);
const { data: snapshotsData } = useResourceByName("reports").useList();
const snapshotOptions = React.useMemo(() => {
const options: { label: string; value: string | null }[] = [
{ label: "Latest (auto)", value: null },
];
if (snapshotsData?.data) {
for (const snap of snapshotsData.data) {
options.push({
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
value: snap.snapshot_id,
});
}
}
return options;
}, [snapshotsData]);
const selectedSnapshotOption = snapshotOptions.find((o) => o.value === selectedSnapshotId) ?? snapshotOptions[0];
const report = useReport({ const report = useReport({
snapshot_id: selectedSnapshotId ?? undefined,
periods: ["daily", "weekly", "monthly", "all"], periods: ["daily", "weekly", "monthly", "all"],
flow: state.flow, flow: state.flow,
payee: appliedPayees.length > 0 ? appliedPayees : undefined, payee: appliedPayees.length > 0 ? appliedPayees : undefined,
@@ -83,10 +50,10 @@ export default function Dashboard() {
}); });
React.useEffect(() => { React.useEffect(() => {
if (report.data) { if (report.data?.data) {
setLoadedPayees(prev => { setLoadedPayees(prev => {
const pSet = new Set<string>(prev); const pSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => { report.data.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => { Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => { periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => { p.metric?.transactions?.forEach((t: any) => {
@@ -100,7 +67,7 @@ export default function Dashboard() {
setLoadedTags(prev => { setLoadedTags(prev => {
const tSet = new Set<string>(prev); const tSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => { report.data.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => { Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => { periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => { p.metric?.transactions?.forEach((t: any) => {
@@ -112,7 +79,7 @@ export default function Dashboard() {
return Array.from(tSet).sort(); return Array.from(tSet).sort();
}); });
} }
}, [report.data]); }, [report.data?.data]);
const toggleFlow = const toggleFlow =
React.useCallback(() => { React.useCallback(() => {
@@ -252,7 +219,7 @@ export default function Dashboard() {
return null; return null;
} }
const data = prepareReport(report.data); const data = prepareReport(report.data.data);
return ( return (
<Box> <Box>
<Container> <Container>
@@ -298,21 +265,6 @@ export default function Dashboard() {
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }} sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/> />
</Box> </Box>
<Box sx={{ display: 'flex', flexDirection: 'column', minWidth: { sm: 220 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Snapshot
</Box>
<Autocomplete
options={snapshotOptions}
value={selectedSnapshotOption}
onChange={(_, option) => setSelectedSnapshotId(option?.value ?? null)}
getOptionLabel={(o) => o.label}
isOptionEqualToValue={(o, v) => o.value === v.value}
renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Button <Button
variant="contained" variant="contained"
size="large" size="large"

View File

@@ -1,674 +0,0 @@
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 {
useFetchRequestAmbiguities,
useResolveAmbiguity,
} from "./features/fetch-requests";
import type {
FetchRequestStatus,
SSEEvent,
ProgressMessage,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useResourceByName, useConfig, defaultFieldComponents } 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 { useRead, usePatch } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useRead(id!);
const updateMutation = usePatch();
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 (err: any) {
setFailNotif(formatApiError(err));
}
};
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

@@ -1,600 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
Button,
ToggleButtonGroup,
ToggleButton,
Chip,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Tooltip,
TextField,
Select,
MenuItem,
InputLabel,
FormControl,
OutlinedInput,
Autocomplete,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import 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 {
useUploadFile,
} from "./features/fetch-requests";
import type {
FetchRequest,
FetchRequestStatus,
FileSource,
EmailSource,
} from "./features/fetch-requests";
import { RETRY_MAX, formatApiError } from "./features/fetch-requests";
import { useNavigate } from "react-router-dom";
import { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
import type { ResourceField } 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: <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) {
const d = new Date(iso);
return d.toLocaleString();
}
function formatDateRange(start?: string, end?: string) {
if (!start && !end) return "\u2014";
const s = start ? new Date(start).toLocaleDateString() : "?";
const e = end ? new Date(end).toLocaleDateString() : "?";
return `${s} \u2192 ${e}`;
}
function shortId(fp: string) {
return fp.length > 8 ? fp.slice(0, 8) + "\u2026" : fp;
}
export default function FetchRequests() {
const navigate = useNavigate();
const [sourceType, setSourceType] = React.useState<"file" | "email">("file");
const [accountName, setAccountName] = React.useState("");
const [payorUsername, setPayorUsername] = React.useState("aetos");
const [format, setFormat] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [uploadedPath, setUploadedPath] = React.useState<string | null>(null);
const [fromEmail, setFromEmail] = React.useState("");
const [subject, setSubject] = React.useState("");
const [rawTerms, setRawTerms] = React.useState("");
const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null);
const [statusFilter, setStatusFilter] = React.useState<string[]>([]);
const [accountFilter, setAccountFilter] = React.useState("");
const [sourceFilter, setSourceFilter] = React.useState<"all" | "file" | "email">("all");
const { useList, useCreate, usePatch, useDelete, components } = useResourceByName("fetch-requests", { fieldComponents: defaultFieldComponents });
const { data: listData, isLoading, isFetching, refetch } = useList({
...(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 formatField: ResourceField | undefined = fetchRes?.fields?.source?.schema?.format;
const formatOptions: string[] = formatField?.options ?? [];
const startDateField: ResourceField | undefined = fetchRes?.fields?.start_date;
const endDateField: ResourceField | undefined = fetchRes?.fields?.end_date;
const payorUsernameField: ResourceField | undefined = fetchRes?.fields?.payor_username;
const createMutation = useCreate();
const updateMutation = usePatch();
const deleteMutation = useDelete();
const uploadMutation = useUploadFile();
const requests = listData?.data ?? [];
const handleUpload = async () => {
if (!file) return;
const result = await uploadMutation.mutateAsync(file);
if (result?.saved_as) {
setUploadedPath(result.saved_as);
if (!format) setFormat(file.name.split(".").pop() || "");
setSnackbar({ message: `File uploaded: ${result.saved_as}`, severity: "success" });
}
};
const handleCreate = async () => {
if (!accountName) return;
let source: FileSource | EmailSource;
if (sourceType === "file") {
if (!uploadedPath || !format) return;
source = { path: uploadedPath, format } as FileSource;
} else {
if (!format) return;
const emailSource: EmailSource = { format };
if (fromEmail) emailSource.from_email = fromEmail;
if (subject) emailSource.subject = subject;
if (rawTerms.trim()) emailSource.raw_terms = rawTerms.split(",").map((s) => s.trim()).filter(Boolean);
source = emailSource;
}
try {
const result = await createMutation.mutateAsync({
source,
account_name: accountName,
payor_username: payorUsername,
...(startDate ? { start_date: new Date(startDate).toISOString() } : {}),
...(endDate ? { end_date: new Date(endDate).toISOString() } : {}),
});
setSnackbar({ message: "Fetch request created", severity: "success" });
resetForm();
navigate(`/fetch-requests/${result.id}`);
} catch (err: any) {
if (err?.response?.status === 409) {
setSnackbar({ message: "Duplicate \u2014 same fingerprint already exists", severity: "error" });
} else {
setSnackbar({ message: formatApiError(err) || "Failed to create fetch request", severity: "error" });
}
}
};
const resetForm = () => {
setAccountName("");
setFormat("");
setFile(null);
setUploadedPath(null);
setFromEmail("");
setSubject("");
setRawTerms("");
setStartDate("");
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 () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
setSnackbar({ message: "Fetch request deleted", severity: "success" });
} catch {
setSnackbar({ message: "Failed to delete", severity: "error" });
}
setDeleteTarget(null);
};
const sourceTypeOptions: ("all" | "file" | "email")[] = ["all", "file", "email"];
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline
</Typography>
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
New Fetch Request
</Typography>
<ToggleButtonGroup
value={sourceType}
exclusive
onChange={(_, val) => val && setSourceType(val)}
sx={{ mb: 3 }}
size="small"
>
<ToggleButton value="file">File Upload</ToggleButton>
<ToggleButton value="email">Email Fetch</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{sourceType === "file" ? (
<>
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
<Button variant="outlined" component="label" startIcon={<CloudUploadIcon />}>
Choose File
<input type="file" hidden onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</Button>
<Typography variant="body2" sx={{ flex: 1, color: "text.secondary" }}>
{file ? file.name : "No file selected"}
</Typography>
<Button
variant="contained"
onClick={handleUpload}
disabled={!file || uploadMutation.isPending}
>
{uploadMutation.isPending ? "Uploading..." : "Upload"}
</Button>
</Box>
{uploadedPath && (
<Alert severity="success" sx={{ py: 0 }}>
Uploaded as: {uploadedPath}
</Alert>
)}
{formatField && components?.FormField ? (
<components.FormField
name="format"
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<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>
)}
</>
) : (
<>
{formatField && components?.FormField ? (
<components.FormField
name="format"
field={formatField}
value={format}
onChange={setFormat}
/>
) : (
<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="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" />
</>
)}
<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" } }}
/>
{payorUsernameField && components?.FormField ? (
<components.FormField
name="payor_username"
field={payorUsernameField}
value={payorUsername}
onChange={setPayorUsername}
/>
) : (
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField && components?.date ? (
<Box sx={{ flex: 1 }}>
<components.date
name="start_date"
field={startDateField}
value={startDate}
onChange={setStartDate}
/>
</Box>
) : (
<TextField
label="Start Date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)}
{endDateField && components?.date ? (
<Box sx={{ flex: 1 }}>
<components.date
name="end_date"
field={endDateField}
value={endDate}
onChange={setEndDate}
/>
</Box>
) : (
<TextField
label="End Date"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ max: new Date().toISOString().split("T")[0] }}
sx={{ flex: 1 }}
/>
)}
</Box>
<Button
variant="contained"
onClick={handleCreate}
disabled={createMutation.isPending || !accountName || (sourceType === "file" && (!uploadedPath || !format)) || (sourceType === "email" && !format)}
>
{createMutation.isPending ? "Creating..." : "Create Fetch Request"}
</Button>
</Box>
</Paper>
<Paper sx={{ borderRadius: 4, mb: 2, p: 2 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Status</InputLabel>
<Select
multiple
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as string[])}
input={<OutlinedInput label="Status" />}
renderValue={(selected) => (selected as string[]).join(", ")}
>
{(config?.enums?.FetchRequestStatus ?? []).map((s: string) => (
<MenuItem key={s} value={s}>{s.replace(/_/g, " ")}</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
options={accountOptions}
value={accountFilter || null}
onChange={(_, val) => setAccountFilter(val ?? "")}
renderInput={(params) => (
<TextField {...params} label="Account" size="small" sx={{ minWidth: 160 }} />
)}
sx={{ minWidth: 160, "& .MuiOutlinedInput-root": { height: "auto", minHeight: "2.5rem" } }}
/>
<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}>
<RefreshIcon />
</IconButton>
</Box>
</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>
) : (
<Paper variant="outlined" sx={{ borderRadius: 4 }}>
<Box sx={{ overflowX: "auto" }}>
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
<Box component="thead">
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
{["ID", "Account", "Source", "Date Range", "Status", "Retries", "Created", "Actions"].map((h) => (
<Box
key={h}
component="th"
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
>
{h}
</Box>
))}
</Box>
</Box>
<Box component="tbody">
{[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => (
<Box
key={req.id}
component="tr"
onClick={() => navigate(`/fetch-requests/${req.id}`)}
sx={{
cursor: "pointer",
borderBottom: 1,
borderColor: "divider",
"&:hover": { bgcolor: "action.hover" },
"&:last-child": { borderBottom: 0 },
}}
>
<Box component="td" sx={{ px: 2, py: 1.5, 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>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
{req.account_name}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{formatDateRange((req as any).start_date, (req as any).end_date)}
</Typography>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<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>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{(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" }}>
\u2014
</Typography>
)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, whiteSpace: "nowrap", fontSize: "0.8rem" }}>
{formatDate(req.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<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>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
</Paper>
)}
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Fetch Request?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the fetch request and all associated data.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

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

View File

@@ -1,180 +1,71 @@
import * as React from "react"; import * as React from "react";
import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material"; import { Box, Typography, Button, Container, Stack } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { useTheme, alpha } from "@mui/material/styles";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/Dashboard";
import SyncIcon from "@mui/icons-material/Sync";
import BarChartIcon from "@mui/icons-material/BarChart";
import SettingsIcon from "@mui/icons-material/Settings";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { useAuth } from "../react-auth";
interface FeatureCardProps {
icon: React.ReactNode;
title: string;
description: string;
path: string;
label?: string;
accent: string;
}
function FeatureCard({ icon, title, description, path, label, accent }: FeatureCardProps) {
const navigate = useNavigate();
const theme = useTheme();
return (
<Paper
elevation={0}
onClick={() => navigate(path)}
sx={{
p: 3,
borderRadius: 3,
border: "1px solid",
borderColor: "divider",
cursor: "pointer",
height: "100%",
display: "flex",
flexDirection: "column",
position: "relative",
overflow: "hidden",
transition: "all 0.25s ease",
"&::before": {
content: '""',
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 3,
background: accent,
opacity: 0,
transition: "opacity 0.25s ease",
},
"&:hover": {
transform: "translateY(-4px)",
boxShadow: `0 12px 32px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.3 : 0.08)}`,
borderColor: "transparent",
"&::before": { opacity: 1 },
},
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1.5 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: alpha(accent, 0.12),
color: accent,
}}
>
{icon}
</Box>
<Typography variant="subtitle1" fontWeight={700}>
{title}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, lineHeight: 1.6 }}>
{description}
</Typography>
{label && (
<Chip
label={label}
size="small"
variant="outlined"
sx={{ mt: 2, alignSelf: "flex-start", textTransform: "capitalize" }}
/>
)}
</Paper>
);
}
export default function Home() { export default function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
const { currentUser } = useAuth();
const features = [
{
icon: <DashboardIcon />,
title: "Dashboard",
description: "Visualise inflows and outflows with interactive charts, drill into categories, and track trends over daily, weekly, and monthly periods.",
path: "/dashboard",
accent: theme.palette.mode === "dark" ? "#818cf8" : "#6366f1",
},
{
icon: <SyncIcon />,
title: "Fetch Requests",
description: "Upload bank statements or configure email ingestion to auto-import transactions. Track pipeline status from pending through to completion.",
path: "/fetch-requests",
accent: theme.palette.mode === "dark" ? "#34d399" : "#10b981",
},
{
icon: <BarChartIcon />,
title: "Report Snapshots",
description: "Generate cached report snapshots with custom filters — accounts, date ranges, amount bounds — then pin a snapshot on the dashboard for consistent comparisons.",
path: "/reports",
accent: theme.palette.mode === "dark" ? "#fbbf24" : "#f59e0b",
},
{
icon: <SettingsIcon />,
title: "Admin",
description: "Full CRUD over accounts, expenses, tags, and payors. Manage your data programmatically through the OpenAPI-driven admin panel.",
path: "/admin",
accent: theme.palette.mode === "dark" ? "#e879f9" : "#d946ef",
},
];
return ( return (
<Box <Box
sx={{ sx={{
minHeight: "calc(100vh - 64px)", width: "100%",
minHeight: "calc(100vh - 64px)", // accounting for header
display: "flex", display: "flex",
flexDirection: "column", alignItems: "center",
justifyContent: "center",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
"&::before": { "&::before": {
content: '""', content: '""',
position: "absolute", position: "absolute",
top: "-15%", top: "-20%",
left: "-8%", left: "-10%",
width: "45%", width: "50%",
height: "55%", height: "60%",
background: "radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)", background: "radial-gradient(circle, rgba(99,102,241,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0, zIndex: 0,
}, },
"&::after": { "&::after": {
content: '""', content: '""',
position: "absolute", position: "absolute",
bottom: "-15%", bottom: "-20%",
right: "-8%", right: "-10%",
width: "45%", width: "50%",
height: "55%", height: "60%",
background: "radial-gradient(circle, rgba(236,72,153,0.1) 0%, transparent 70%)", background: "radial-gradient(circle, rgba(236,72,153,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0, zIndex: 0,
}, },
}} }}
> >
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1, flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", py: 6 }}> <Container maxWidth="lg" sx={{ position: "relative", zIndex: 1 }}>
<Box <Stack
spacing={4}
alignItems="center"
textAlign="center"
sx={{ sx={{
textAlign: "center", p: { xs: 4, md: 8 },
mb: 6, backdropFilter: "blur(20px)",
backgroundColor: (t) => alpha(t.palette.common.white, t.palette.mode === "dark" ? 0.04 : 0.6),
border: "1px solid",
borderColor: "divider",
borderRadius: 4,
boxShadow: (t) =>
t.palette.mode === "dark"
? "0 8px 32px 0 rgba(0, 0, 0, 0.5)"
: "0 8px 32px 0 rgba(31, 38, 135, 0.07)",
}} }}
> >
<Typography <Typography
variant="h1" variant="h1"
sx={{ sx={{
fontWeight: 800, fontWeight: 800,
fontSize: { xs: "2.5rem", sm: "3.5rem", md: "5rem" }, fontSize: { xs: "3rem", md: "5rem" },
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 50%, #f59e0b 100%)", background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
WebkitBackgroundClip: "text", WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent", WebkitTextFillColor: "transparent",
letterSpacing: "-0.03em",
mb: 2, mb: 2,
}} }}
> >
@@ -182,20 +73,14 @@ export default function Home() {
</Typography> </Typography>
<Typography <Typography
variant="h6" variant="h5"
color="text.secondary" color="text.secondary"
sx={{ sx={{ maxWidth: "600px", lineHeight: 1.6 }}
maxWidth: 580,
mx: "auto",
lineHeight: 1.7,
fontWeight: 400,
fontSize: { xs: "1rem", md: "1.15rem" },
}}
> >
Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow. Your intelligent, extensible financial ledger. Control accounts, manage transactions, and track your data dynamically with our OpenAPI-driven architecture.
</Typography> </Typography>
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: "center", flexWrap: "wrap" }}> <Box mt={4}>
<Button <Button
variant="contained" variant="contained"
size="large" size="large"
@@ -203,44 +88,21 @@ export default function Home() {
onClick={() => navigate("/dashboard")} onClick={() => navigate("/dashboard")}
sx={{ sx={{
px: 4, px: 4,
py: 1.4, py: 1.5,
borderRadius: "50px", borderRadius: "50px",
fontWeight: 700, fontWeight: "bold",
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)", background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
transition: "transform 0.2s ease, box-shadow 0.2s", transition: "transform 0.2s ease-in-out, box-shadow 0.2s",
"&:hover": { "&:hover": {
transform: "translateY(-2px)", transform: "translateY(-3px)",
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.35)}`, boxShadow: (t) => `0 8px 20px ${alpha(t.palette.primary.main, 0.4)}`,
}, },
}} }}
> >
Enter Dashboard Enter Dashboard
</Button> </Button>
<Button
variant="outlined"
size="large"
onClick={() => navigate("/fetch-requests")}
sx={{
px: 4,
py: 1.4,
borderRadius: "50px",
fontWeight: 600,
borderWidth: 2,
"&:hover": { borderWidth: 2 },
}}
>
Import Data
</Button>
</Box> </Box>
</Box> </Stack>
<Grid container spacing={3}>
{features.map((f) => (
<Grid key={f.title} size={{ xs: 12, sm: 6, md: 3 }}>
<FeatureCard {...f} />
</Grid>
))}
</Grid>
</Container> </Container>
</Box> </Box>
); );

View File

@@ -1,308 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
Button,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
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 { useResourceByName, useConfig, defaultFieldComponents } from "../react-openapi";
import type { ResourceField } from "../react-openapi";
interface ReportSnapshotQuery {
accounts?: string[];
ignore_self?: boolean;
start_date?: string;
end_date?: string;
}
interface ReportSnapshot {
id: string;
snapshot_id: string;
created_at: string;
query?: ReportSnapshotQuery;
}
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
}
export default function ReportSnapshots() {
const [ignoreSelf, setIgnoreSelf] = React.useState(true);
const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [minAmount, setMinAmount] = React.useState("");
const [maxAmount, setMaxAmount] = React.useState("");
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
const { useList, useCreate, useDelete, components } = useResourceByName("reports", { fieldComponents: defaultFieldComponents });
const { data: listData, isLoading, isFetching, refetch } = useList();
const createMutation = useCreate();
const deleteMutation = useDelete();
const config = useConfig();
const reportsRes = config?.resources.find((r: any) => r.name === "reports");
const ignoreSelfField: ResourceField | undefined = reportsRes?.fields?.ignore_self;
const startDateField: ResourceField | undefined = reportsRes?.fields?.start_date;
const endDateField: ResourceField | undefined = reportsRes?.fields?.end_date;
const minAmountField: ResourceField | undefined = reportsRes?.fields?.min_amount;
const maxAmountField: ResourceField | undefined = reportsRes?.fields?.max_amount;
const snapshots: ReportSnapshot[] = listData?.data ?? [];
const handleCreate = async () => {
try {
const payload: Record<string, any> = {};
if (ignoreSelf) payload.ignore_self = true;
if (startDate) payload.start_date = new Date(startDate).toISOString();
if (endDate) payload.end_date = new Date(endDate).toISOString();
if (minAmount) payload.min_amount = parseFloat(minAmount);
if (maxAmount) payload.max_amount = parseFloat(maxAmount);
const result = await createMutation.mutateAsync(payload);
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(true);
setStartDate("");
setEndDate("");
setMinAmount("");
setMaxAmount("");
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.snapshot_id);
setSnackbar({ message: "Snapshot deleted", severity: "success" });
} catch {
setSnackbar({ message: "Failed to delete snapshot", severity: "error" });
}
setDeleteTarget(null);
};
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Report Snapshots
</Typography>
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Generate New Snapshot
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{ignoreSelfField && components?.FormField && (
<components.FormField
name="ignore_self"
field={ignoreSelfField}
value={ignoreSelf}
onChange={(val: boolean) => setIgnoreSelf(val)}
/>
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField && components?.datetime && (
<Box sx={{ flex: 1 }}>
<components.datetime
name="start_date"
field={startDateField}
value={startDate}
onChange={(val: string) => setStartDate(val)}
/>
</Box>
)}
{endDateField && components?.datetime && (
<Box sx={{ flex: 1 }}>
<components.datetime
name="end_date"
field={endDateField}
value={endDate}
onChange={(val: string) => setEndDate(val)}
/>
</Box>
)}
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
{minAmountField && components?.FormField && (
<Box sx={{ flex: 1 }}>
<components.FormField
name="min_amount"
field={minAmountField}
value={minAmount}
onChange={(val: string) => setMinAmount(val)}
/>
</Box>
)}
{maxAmountField && components?.FormField && (
<Box sx={{ flex: 1 }}>
<components.FormField
name="max_amount"
field={maxAmountField}
value={maxAmount}
onChange={(val: string) => setMaxAmount(val)}
/>
</Box>
)}
</Box>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={handleCreate}
disabled={createMutation.isPending}
>
{createMutation.isPending ? "Generating..." : "Generate Snapshot"}
</Button>
{createdSnapshotId && (
<Alert severity="success" onClose={() => setCreatedSnapshotId(null)}>
Snapshot created: <strong>{createdSnapshotId}</strong>. Use it in the Dashboard snapshot selector.
</Alert>
)}
</Box>
</Paper>
<Paper sx={{ borderRadius: 4 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600}>
Existing Snapshots
</Typography>
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : snapshots.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No snapshots yet
</Box>
) : (
<Box sx={{ overflowX: "auto" }}>
<Box component="table" sx={{ width: "100%", borderCollapse: "collapse" }}>
<Box component="thead">
<Box component="tr" sx={{ borderBottom: 1, borderColor: "divider" }}>
{["Snapshot ID", "Created", "Query", "Actions"].map((h) => (
<Box
key={h}
component="th"
sx={{ px: 2, py: 1.5, textAlign: h === "Actions" ? "right" : "left", fontWeight: 600, fontSize: "0.8rem", color: "text.secondary", whiteSpace: "nowrap" }}
>
{h}
</Box>
))}
</Box>
</Box>
<Box component="tbody">
{snapshots.map((snap: ReportSnapshot) => (
<Box
key={snap.id}
component="tr"
sx={{ borderBottom: 1, borderColor: "divider", "&:last-child": { borderBottom: 0 }, "&:hover": { bgcolor: "action.hover" } }}
>
<Box component="td" sx={{ px: 2, py: 1.5, fontFamily: "monospace", fontSize: "0.8rem" }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{snap.snapshot_id}
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(snap.snapshot_id);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
<Box component="td" sx={{ px: 2, py: 1.5, fontSize: "0.875rem" }}>
{formatDate(snap.created_at)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
{snap.query ? (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
{snap.query.ignore_self && <Chip label="ignore_self" size="small" variant="outlined" />}
{snap.query.start_date && <Chip label="start" size="small" variant="outlined" />}
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
</Box>
) : (
<Typography variant="body2" color="text.secondary">\u2014</Typography>
)}
</Box>
<Box component="td" sx={{ px: 2, py: 1.5 }}>
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</Box>
))}
</Box>
</Box>
</Box>
)}
</Paper>
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Snapshot?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the report snapshot.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -1,133 +0,0 @@
export type FetchRequestStatus =
| "pending"
| "processing"
| "paused"
| "raw_expenses_done"
| "enriched_done"
| "completed"
| "failed";
export interface FileSource {
path: 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 {
format: string;
from_email?: string;
subject?: string;
raw_terms?: string[];
txn_dict_count?: number;
txn_dicts_count?: number;
}
export interface FetchRequestCreate {
source: FileSource | EmailSource;
account_name: string;
payor_username?: string;
start_date?: string;
end_date?: string;
}
export interface FetchRequestUpdate {
status?: FetchRequestStatus;
error_message?: string | null;
}
export interface FetchRequest extends FetchRequestCreate {
id: string;
status: FetchRequestStatus;
fingerprint: string;
completed_at?: string | null;
error_message?: string | null;
retry_count?: number;
created_at: string;
}
export interface UploadResult {
original_filename: string;
saved_as: string;
content_type: string;
url: 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 function formatApiError(err: any): string {
if (!err?.response) return err?.message || "Request failed";
const data = err.response.data;
const status = err.response.status;
if (status === 422 && Array.isArray(data?.detail)) {
return data.detail.map((d: any) => {
const field = d.loc?.filter((s: string) => s !== "body").pop() || "field";
if (d.type === "value_error.missing") return `Missing: ${field}`;
return `${field}: ${d.msg}`;
}).join("; ");
}
if (typeof data?.detail === "string") return data.detail;
return `Request failed (${status})`;
}
export const RETRY_MAX = 3;

View File

@@ -1,23 +0,0 @@
export type {
FetchRequest,
FetchRequestCreate,
FetchRequestUpdate,
FetchRequestStatus,
FetchRequestFilters,
FileSource,
EmailSource,
UploadResult,
PendingAmbiguity,
AmbiguityCandidate,
ResolveAmbiguityPayload,
SSEEvent,
SSEEventStep,
SSEEventStatus,
ProgressMessage,
} from "./fetch-requests.models";
export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
export {
useUploadFile,
useFetchRequestAmbiguities,
useResolveAmbiguity,
} from "./useFetchRequests";

View File

@@ -1,90 +0,0 @@
import { useResourceByName } from "../../../react-openapi";
import { api } from "../../../react-openapi/api/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ResolveAmbiguityPayload } from "./fetch-requests.models";
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 useUpdateFetchRequest() {
const { usePatch } = useResourceByName("fetch-requests");
return usePatch();
}
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;
},
});
}
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

@@ -1,4 +0,0 @@
export type {
ReportSnapshot,
ReportQuery,
} from "./report-snapshots.models";

View File

@@ -1,15 +0,0 @@
export interface ReportQuery {
accounts?: string[] | null;
ignore_self?: boolean | null;
start_date?: string | null;
end_date?: string | null;
min_amount?: number | null;
max_amount?: number | null;
}
export interface ReportSnapshot {
id: string;
snapshot_id: string;
created_at: string;
query?: ReportQuery;
}

View File

@@ -1,16 +0,0 @@
import { useResourceByName } from "../../../react-openapi";
export function useReportSnapshotsList() {
const { useList } = useResourceByName("reports");
return useList();
}
export function useCreateSnapshot() {
const { useCreate } = useResourceByName("reports");
return useCreate();
}
export function useDeleteSnapshot() {
const { useDelete } = useResourceByName("reports");
return useDelete();
}

View File

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

View File

@@ -12,10 +12,7 @@ import {
} from "@mui/material"; } from "@mui/material";
import Home from './Home'; import Home from './Home';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import FetchRequests from './FetchRequests'; import { Admin, AppProvider } from '../react-openapi';
import FetchRequestDetail from './FetchRequestDetail';
import ReportSnapshots from './ReportSnapshots';
import { Admin, AppProvider, defaultFieldComponents } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config'; import { configuration, profileConfiguration } from './openapi-config';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import process from 'process'; import process from 'process';
@@ -36,9 +33,6 @@ const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" }, { path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", component: Home, headerTitle: "Home" }, { path: "/home", component: Home, headerTitle: "Home" },
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" }, { path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" },
{ path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" },
{ path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" },
{ path: "/reports", component: ReportSnapshots, headerTitle: "Reports" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" },
]; ];
@@ -60,7 +54,7 @@ root.render(
path={path} path={path}
element={ element={
path.startsWith("/admin") ? ( path.startsWith("/admin") ? (
<Component basePath="/admin" fieldComponents={{ ...defaultFieldComponents }} /> <Component basePath="/admin" />
) : ( ) : (
<Component /> <Component />
) )

View File

@@ -1,32 +1,22 @@
import { ResourceOverride } from "../react-openapi"; 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: {
displayFormat: "{name}", displayField: "name",
filterType: "autocomplete",
}, },
payor: { payor: {
display: false, display: false,
displayFormat: "{username}", displayField: "username",
}, },
account: { account: {
displayFormat: "{name}", displayField: "name",
filterType: "multiselect",
refers: "accounts"
}, },
tags: { tags: {
displayFormat: "{icon} {name}", displayField: ["name", "icon"],
filterType: "autocomplete",
refers: "tags"
}, },
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();
@@ -44,47 +34,15 @@ export const configuration: Record<string, ResourceOverride> = {
return `${day}${suffix(day)} ${month} ${year}`; return `${day}${suffix(day)} ${month} ${year}`;
} }
}, },
amount: {
filterType: "number-range",
},
created_at: { created_at: {
display: false display: false
} }
}, },
pagination: true,
}, },
'fetch-requests': { reports: {
fields: { hidden: true
format: {
path: 'source.format',
},
// account: {
// refers: 'accounts',
// },
// tags: {
// refers: 'tags',
// },
},
},
accounts: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{name} - XX{number}',
},
autoComplete: true,
prefetch: true,
} }
},
tags: {
referenceOptions: {
enumOption: {
key: 'id',
value: '{icon} {name}',
},
autoComplete: true,
prefetch: true,
}
},
}; };
export const profileConfiguration = { export const profileConfiguration = {