31 Commits

Author SHA1 Message Date
7470da6d2d all period types 2026-05-05 13:26:35 +05:30
34594215f9 using new state management 2026-05-05 12:58:59 +05:30
0a92126b92 toggleComparison 2026-05-05 12:58:45 +05:30
30cf227050 state controlled by Dashboard.models.ts 2026-05-05 12:26:02 +05:30
a0e62b1bc4 fixes 2026-05-05 11:42:33 +05:30
ea3b451266 removed latest items for now 2026-05-05 11:42:27 +05:30
4b4875c3f5 removed latest items for now 2026-05-05 11:39:38 +05:30
25bd882b75 dashboard using report feature 2026-05-05 11:39:23 +05:30
f684083496 report models and prepareReport function 2026-05-05 11:39:07 +05:30
0e0928af95 report models and prepareReport function 2026-05-02 14:25:55 +05:30
7b0b3fb615 period selection 2026-05-01 17:40:54 +05:30
38f7416942 refactor period (rolling/calender) to periodType 2026-05-01 17:17:17 +05:30
e82cad4f21 refactor period (rolling/calender) to periodType 2026-05-01 17:15:16 +05:30
1daa90d091 refactor period (rolling/calender) to periodType 2026-05-01 17:13:13 +05:30
2d0b0bc470 fixes for correct comparison period 2026-05-01 17:07:35 +05:30
5f85abdf86 use full for fast loading than weekly for payee report 2026-05-01 16:59:56 +05:30
cc7e6509d2 theme changes 2026-04-25 13:47:42 +05:30
8a3ebdb1be better name 2026-04-25 13:22:49 +05:30
a36d9119bb configurable Dashboard.tsx 2026-04-25 13:21:34 +05:30
67d4c85146 fixes 2026-04-25 12:49:58 +05:30
89ad8e376e Progress Cards for Top Payees 2026-04-25 12:41:05 +05:30
71afc157ff proper use of react-openapi for resource api calls 2026-04-25 12:13:28 +05:30
5acbb7ccdd hiding report resource 2026-04-24 14:55:38 +05:30
3fd20f11ab hidden resource 2026-04-24 14:55:29 +05:30
922d05ae37 dashboard feature to use dashboard data 2026-04-24 14:41:19 +05:30
1fe44abfde removed client data massaging with backend report using feature/report 2026-04-24 14:27:20 +05:30
49bdb85088 refactored ProgressCard to component 2026-04-24 14:04:24 +05:30
b1509fd5ab refactored LatestItems to component 2026-04-24 14:01:49 +05:30
175ca64d1f refactored HistoryChart to component 2026-04-24 13:58:12 +05:30
c9e609fee6 header fixes 2026-04-11 11:23:06 +05:30
82264a5c34 color pallete 2026-04-07 13:47:55 +05:30
88 changed files with 1680 additions and 5160 deletions

View File

@@ -9,7 +9,6 @@
rel="stylesheet"
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>
</head>
<body>

14
package-lock.json generated
View File

@@ -28,7 +28,6 @@
},
"devDependencies": {
"@vitejs/plugin-react": "latest",
"typescript": "^6.0.3",
"vite": "latest"
}
},
@@ -4104,19 +4103,6 @@
"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": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View File

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

View File

@@ -1,5 +1,4 @@
import axios, { AxiosInstance } from "axios";
import type { AxiosResponse } from "axios";
import { createApiClient } from "../../react-auth";
/**
@@ -8,48 +7,22 @@ import { createApiClient } from "../../react-auth";
let _api: AxiosInstance | null = null;
let _auth: AxiosInstance | null = null;
function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
instance.defaults.paramsSerializer = {
serialize: (params) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => {
searchParams.append(key, String(v)); // NO []
});
} else if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
return searchParams.toString();
},
};
return instance;
}
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");
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");
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");
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");
return _api.delete<T, R>(url, config);
},
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);
return _api.delete(...args);
},
};
@@ -65,6 +38,6 @@ export const auth = {
};
export function initializeApiClients(baseUrl: string, authBaseUrl: string) {
_api = withParamsSerializer(createApiClient(baseUrl));
_auth = withParamsSerializer(createApiClient(authBaseUrl));
_api = createApiClient(baseUrl);
_auth = createApiClient(authBaseUrl);
}

View File

@@ -31,8 +31,6 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useNavigate } from 'react-router-dom';
import { ResourceConfig } from '../types/config';
import { EnhancedTableComponents } from '../types/overrides';
import { getFieldOptions, toGridValueOptions, resolveTemplate } from '../utils/options';
interface EnhancedTableProps {
config: ResourceConfig;
@@ -45,33 +43,23 @@ interface EnhancedTableProps {
onDelete: (id: string) => void;
onCreate: () => void;
onNavigateToResource?: (resourceName: string, id: string) => void;
components?: EnhancedTableComponents;
}
export default function EnhancedTable({
config,
data,
total,
paginationModel: externalPaginationModel,
onPaginationModelChange: externalOnPaginationModelChange,
paginationModel,
onPaginationModelChange,
loading = false,
onEdit,
onDelete,
onCreate,
onNavigateToResource,
components: tableComponents,
}: EnhancedTableProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
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 cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
@@ -88,7 +76,7 @@ export default function EnhancedTable({
type: muiType,
flex: 1,
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') {
@@ -99,8 +87,9 @@ export default function EnhancedTable({
};
}
if (muiType === 'singleSelect') {
(col as GridColDef & { valueOptions: any[] }).valueOptions = toGridValueOptions(getFieldOptions(field));
if (muiType === 'singleSelect' && field.options) {
// @ts-ignore
col.valueOptions = field.options;
}
return col;
@@ -133,15 +122,6 @@ export default function EnhancedTable({
return cols;
}, [config, onDelete, navigate, onNavigateToResource]);
const mobilePageSize = 10;
const [mobilePage, setMobilePage] = React.useState(0);
const mobileTotalPages = Math.ceil(data.length / mobilePageSize) || 1;
const mobileData = data.slice(mobilePage * mobilePageSize, (mobilePage + 1) * mobilePageSize);
React.useEffect(() => {
if (mobilePage >= mobileTotalPages) setMobilePage(0);
}, [data.length, mobilePage, mobileTotalPages]);
if (isMobile) {
return (
<Box>
@@ -152,7 +132,7 @@ export default function EnhancedTable({
</Button>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{mobileData.map((row) => (
{data.map((row) => (
<Box key={row[config.primaryKey] || Math.random()}>
<MobileCardRow
row={row}
@@ -161,22 +141,10 @@ export default function EnhancedTable({
onDelete={onDelete}
onNavigate={onNavigateToResource}
navigate={navigate}
components={tableComponents}
/>
</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>
);
}
@@ -193,18 +161,20 @@ export default function EnhancedTable({
rows={data || []}
columns={columns}
autoHeight
paginationMode={isServer ? 'server' : 'client'}
{...(isServer ? {
rowCount: (() => {
if (total !== undefined) return total;
const page = paginationModel?.page || 0;
const pageSize = paginationModel?.pageSize || 10;
if (data.length < pageSize) {
return page * pageSize + data.length;
}
return (page + 2) * pageSize;
})(),
} : {})}
paginationMode={config.pagination ? 'server' : 'client'}
rowCount={(() => {
if (!config.pagination) return data.length;
if (total !== undefined) return total;
// Graceful fallback for missing total count
const page = paginationModel?.page || 0;
const pageSize = paginationModel?.pageSize || 10;
if (data.length < pageSize) {
return page * pageSize + data.length;
}
// Enable 'Next' button by pretending there's at least one more page
return (page + 2) * pageSize;
})()}
loading={loading}
paginationModel={paginationModel || { page: 0, pageSize: 10 }}
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 open = Boolean(anchorEl);
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' }}>
{field.label}
</Typography>
<Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile components={components} />
<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 />
</Typography>
</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 (enumValue) return resolveTemplate(enumValue, item);
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
if (Array.isArray(displayField)) {
@@ -293,21 +262,16 @@ function getFormattedDisplayValue(item: any, displayField?: string | string[], e
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 isPk = fieldKey === config.primaryKey;
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
if (field.relation && value && !Array.isArray(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 (
<Chip
@@ -326,8 +290,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 2. Multi-Select (Array of relations or simple strings)
if (field.type === 'array' && Array.isArray(value)) {
const enumValue = field.enumOption?.value;
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField, enumValue)).join(', ');
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).join(', ');
return (
<Tooltip title={tooltipTitle} arrow placement="top">
@@ -335,7 +298,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
{value.map((item, idx) => (
<Chip
key={idx}
label={getFormattedDisplayValue(item, field.displayField, enumValue)}
label={getFormattedDisplayValue(item, field.displayField)}
size="small"
variant="filled"
sx={{ maxWidth: 120 }}
@@ -355,7 +318,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
// 3. Simple Objects
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') {
@@ -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 === 'enum') {
const opt = getFieldOptions(field).find(o => o.key === value);
return opt?.value ?? value;
}
if (isPk && !isMobile) {
return (
<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,
} from '@mui/material';
import { ResourceConfig } from '../types/config';
import { FieldComponents } from '../types/overrides';
import { useUpload } from '../providers/UploadProvider';
import { useQueries } from '@tanstack/react-query';
import { useResource } from '../hooks/useResource';
@@ -22,7 +21,6 @@ interface GenericFormProps {
loading?: boolean;
readOnly?: boolean;
onEditClick?: () => void;
fieldComponents: FieldComponents;
}
export default function GenericForm({
@@ -33,7 +31,6 @@ export default function GenericForm({
loading: saving,
readOnly = false,
onEditClick,
fieldComponents,
}: GenericFormProps) {
initialData = initialData || {};
const [formData, setFormData] = React.useState(initialData);
@@ -57,7 +54,7 @@ export default function GenericForm({
queries: allRelations.map(relName => {
const relatedRes = appConfig?.resources.find(r => r.name === relName);
// eslint-disable-next-line react-hooks/rules-of-hooks
const { getListQueryOptions } = useResource(relatedRes!, { fieldComponents });
const { getListQueryOptions } = useResource(relatedRes!);
return {
...getListQueryOptions(),
enabled: !!relatedRes,
@@ -120,7 +117,6 @@ export default function GenericForm({
uploading={uploading}
baseUrl={appConfig?.baseUrl || ""}
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 GenericForm from './GenericForm';
import { ConfigContext } from '../providers/ConfigContext';
import { defaultFieldComponents } from './fields/DefaultFieldComponents';
export default function ProfileView() {
const appConfig = React.useContext(ConfigContext);
@@ -14,10 +13,11 @@ export default function ProfileView() {
return <Alert severity="error">Profile configuration not found.</Alert>;
}
// Create a modified config where only extraFields are editable
const editableConfig = React.useMemo(() => {
const newFields = { ...resourceConfig.fields };
const extraFields = profileConfig.extraFields || [];
Object.keys(newFields).forEach(key => {
newFields[key] = {
...newFields[key],
@@ -31,12 +31,13 @@ export default function ProfileView() {
};
}, [resourceConfig, profileConfig.extraFields]);
const { useMe, useUpdateMe } = useResource(resourceConfig, { fieldComponents: defaultFieldComponents });
const { useMe, useUpdateMe } = useResource(resourceConfig);
const { data: profile, isLoading, error } = useMe();
const updateMutation = useUpdateMe();
const handleSave = async (formData: any) => {
try {
// Only send editable fields to prevent accidental overwrites of read-only data
const extraFields = profileConfig.extraFields || [];
const dataToSave = Object.keys(formData)
.filter(key => extraFields.includes(key))
@@ -75,7 +76,6 @@ export default function ProfileView() {
onSave={handleSave}
onCancel={() => window.history.back()}
loading={updateMutation.isPending}
fieldComponents={defaultFieldComponents}
/>
</Paper>
</Box>

View File

@@ -1,142 +1,48 @@
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 type { ResourceField } from '../types/config';
import { FieldComponents } from '../types/overrides';
import { useResource } from '../hooks/useResource';
import { resolveTemplate } from '../utils/options';
import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable';
import FilterBar from './FilterBar';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
interface ResourceViewProps {
config: ResourceConfig;
onNavigateToResource?: (resourceName: string, id: string) => void;
fieldComponents: FieldComponents;
}
import { GridPaginationModel } from '@mui/x-data-grid';
function getDisplayString(item: any, field: ResourceField): string {
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) {
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
const { id } = useParams();
const location = useLocation();
const navigate = useNavigate();
const isCreate = location.pathname.endsWith('/create');
const isEdit = location.pathname.includes('/edit/');
const isView = !!id && !isEdit;
const isList = !id && !isCreate;
const isServer = config.filterOptions?.mode !== "client";
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
const { useList, useRead, useCreate, useUpdate, useDelete, components } = useResource(config, { fieldComponents });
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
// Determine query parameters based on pagination config
const queryParams = React.useMemo(() => {
if (!isServer) return { limit: 10 };
if (!config.pagination) return {};
return {
skip: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize,
};
}, [isServer, paginationModel]);
}, [config.pagination, paginationModel]);
const listQuery = useList(queryParams);
const itemQuery = useRead(id || "");
const rawData = listQuery.data?.data || [];
const totalCount = listQuery.data?.total;
const filteredData = React.useMemo(
() => (isServer ? rawData : applyClientFilters(rawData, appliedFilters, config.fields)),
[isServer, rawData, appliedFilters, config.fields]
);
const paginatedData = listQuery.data || { data: [], total: undefined };
const createMutation = useCreate();
const updateMutation = useUpdate();
const deleteMutation = useDelete();
@@ -174,35 +80,21 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
return (
<Box>
{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
config={config}
data={filteredData}
total={isServer ? totalCount : filteredData.length}
paginationModel={isServer ? paginationModel : undefined}
onPaginationModelChange={isServer ? setPaginationModel : undefined}
loading={listQuery.isFetching}
onEdit={handleEdit}
onDelete={handleDelete}
onCreate={handleCreate}
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
/>
</Box>
<EnhancedTable
config={config}
data={paginatedData.data || []}
total={paginatedData.total}
paginationModel={paginationModel}
onPaginationModelChange={setPaginationModel}
loading={listQuery.isFetching}
onEdit={handleEdit}
onDelete={handleDelete}
onCreate={handleCreate}
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
/>
) : (
<Paper sx={{ p: 4 }}>
{components && <components.GenericForm
<GenericForm
config={config}
initialData={isCreate ? null : itemQuery.data}
onSave={handleSave}
@@ -210,7 +102,7 @@ export default function ResourceView({ config, onNavigateToResource, fieldCompon
loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView}
onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
/>}
/>
</Paper>
)}
</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 {
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Typography,
Box,
Divider,
} from '@mui/material';
import { ResourceField } from '../../types/config';
import { FieldComponentProps, FieldComponents } from '../../types/overrides';
import ObjectField from './ObjectField';
import ImageUploadField from './ImageUploadField';
export interface FormFieldProps {
interface FormFieldProps {
name: string;
field: ResourceField;
value: any;
onChange: (val: any) => void;
disabled?: boolean;
uploadFile?: (file: File) => Promise<string | null>;
uploading?: boolean;
baseUrl?: string;
relationDataMap?: Record<string, any[]>;
components: FieldComponents;
uploadFile: (file: File) => Promise<string | null>;
uploading: boolean;
baseUrl: string;
relationDataMap?: Record<string, any[]>; // Map of relation name to data array
}
export default function FormField({
@@ -26,60 +36,189 @@ export default function FormField({
uploading,
baseUrl,
relationDataMap = {},
components,
}: FormFieldProps) {
const fieldProps: FieldComponentProps = {
name,
field,
value,
onChange,
disabled,
baseUrl,
relationDataMap,
uploadFile,
uploading,
};
const label = field.label;
const childComponents = components;
// 1. Object (recursive) - requires parent FormField for recursion
// 1. Recursive Rendering for Objects (Not Relations)
if (field.type === 'object' && field.schema && !field.relation) {
const renderChild = (childProps: FieldComponentProps) => (
<FormField
name={childProps.name}
field={childProps.field}
value={childProps.value}
onChange={childProps.onChange}
disabled={childProps.disabled}
uploadFile={childProps.uploadFile}
uploading={childProps.uploading}
baseUrl={childProps.baseUrl}
relationDataMap={childProps.relationDataMap}
components={components}
return (
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
<Typography variant="subtitle2" color="primary" gutterBottom>
{label}
</Typography>
<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 getOptionValue = (option: any) => {
// Return the whole object to maintain identity
return option;
};
return (
<FormControl fullWidth>
<InputLabel shrink>{label}</InputLabel>
<Select
multiple={isArrayRelation}
value={value || (isArrayRelation ? [] : "")}
label={label}
displayEmpty
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
renderValue={(selected: any) => {
if (isArrayRelation) {
return (selected as any[]).map(getOptionLabel).join(', ');
}
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
if (field.type === 'image') {
const ImageField = components.image;
if (!ImageField) return null;
return <ImageField {...fieldProps} />;
// 4. Boolean Handling
if (field.type === 'boolean') {
return (
<FormControlLabel
control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={label}
/>
);
}
// 3. Relation
if (field.relation && relationDataMap[field.relation]) {
const RelationFieldComp = components.relation;
if (!RelationFieldComp) return null;
return <RelationFieldComp {...fieldProps} />;
// 5. Enum Handling
if (field.type === 'enum' && field.options) {
return (
<FormControl fullWidth>
<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
const Component = components[field.type] || components.default;
if (Component) {
return <Component {...fieldProps} />;
// 6. Common Text Fields
if (field.type === 'datetime' || field.type === 'date') {
return (
<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,57 +1,41 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import * as React from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
import { ResourceConfig } from "../types/config";
import { ConfigContext } from "../providers/ConfigContext";
import { FieldComponents, FieldComponentProps } from "../types/overrides";
import { defaultFieldComponents } from "../components/fields/DefaultFieldComponents";
import FormField from "../components/fields/FormField";
import GenericForm from "../components/GenericForm";
import * as React from "react";
function wrapFormField(merged: FieldComponents) {
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 }) {
export function useResource<T = any>(config: ResourceConfig | undefined) {
const queryClient = useQueryClient();
// Return empty/disabled hooks if config is missing
const { name = '', endpoint = '', primaryKey = 'id' } = config || {};
const mergedComponents = React.useMemo(
() => options?.fieldComponents ? ({ ...defaultFieldComponents, ...options.fieldComponents }) : undefined,
[options?.fieldComponents],
);
// --- READ ALL ---
const useList = (params?: any) =>
const useList = (params?: any) =>
useQuery({
queryKey: [name, "list", params],
queryFn: async () => {
if (!endpoint) return { data: [], total: 0 };
console.log('params:', params);
// @ts-ignore
const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
return {
data: res.data,
total: isNaN(total as any) ? undefined : total
return {
data: res.data,
total: isNaN(total as any) ? undefined : total
};
},
enabled: !!endpoint,
placeholderData: keepPreviousData,
});
// --- READ ONE ---
const useRead = (id: string, params?: any | null) =>
const useRead = (id: string | null) =>
useQuery({
queryKey: [name, "detail", id, params],
queryKey: [name, "detail", id],
queryFn: async () => {
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;
},
enabled: !!id && !!endpoint,
@@ -62,6 +46,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({
mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.post<T>(endpoint, data);
return res.data;
},
@@ -75,31 +60,18 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.put<T>(`${endpoint}/${id}`, data);
return res.data;
},
onSuccess: (updatedItem: any) => {
onSuccess: (updatedItem) => {
// @ts-ignore
const id = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] });
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 ---
const useDelete = () =>
useMutation({
@@ -118,11 +90,12 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "list", params],
queryFn: async () => {
if (!endpoint) return { data: [], total: 0 };
// @ts-ignore
const res = await api.get<T[]>(endpoint, { params });
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
return {
data: res.data,
total: isNaN(total as any) ? undefined : total
return {
data: res.data,
total: isNaN(total as any) ? undefined : total
};
},
enabled: !!endpoint,
@@ -134,6 +107,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
queryKey: [name, "me"],
queryFn: async () => {
if (!endpoint) return null;
// @ts-ignore
const res = await api.get<T>(`${endpoint}/me`);
return res.data;
},
@@ -145,6 +119,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined, options
useMutation({
mutationFn: async (data: Partial<T>) => {
if (!endpoint) throw new Error("Endpoint not defined");
// @ts-ignore
const res = await api.put<T>(`${endpoint}/me`, data);
return res.data;
},
@@ -154,31 +129,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 {
useList,
useRead,
useMe,
useCreate,
useUpdate,
usePatch,
useUpdateMe,
useDelete,
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 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 { api, auth, initializeApiClients } from "./api/client";
export { getAppConfig } from "./config";
export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config";
export type { FieldComponents, FieldComponentProps, FieldComponent, FieldOverride, ResourceOverride, EnhancedTableComponents, FilterBarComponents, CellRendererProps, CellRenderer } from "./types/overrides";
export type { AppConfig, ResourceConfig, ResourceField } from "./types/config";
export { AppProvider } from "./providers/AppProvider";
export { ConfigContext, useConfig } from "./providers/ConfigContext";
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'
| 'array';
export interface SelectOption {
key: string;
value: string;
}
export interface EnumOption {
key: string;
value: string;
}
export interface ResourceField {
displayFormat: string;
type: FieldType;
label: string;
required?: boolean;
options?: string[];
readOnly?: boolean;
schema?: Record<string, ResourceField>;
displayField?: string | string[];
formatter?: (value: any) => string;
relation?: string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
enumOption?: EnumOption;
enumLabels?: Record<string, string>;
relation?: string; // Name of the target resource
}
export type ResourceMode = "server" | "client";
export interface ResourceConfig {
name: string;
label: string;
@@ -46,18 +31,12 @@ export interface ResourceConfig {
fields: Record<string, ResourceField>;
pagination?: boolean;
hidden?: boolean;
filterOptions?: {
mode?: ResourceMode;
fields?: string[];
};
enumOption?: EnumOption;
}
export interface AppConfig {
baseUrl: string;
authBaseUrl: string;
resources: ResourceConfig[];
enums: Record<string, string[]>;
profile?: {
resource: string;
extraFields?: Record<string, any>;

View File

@@ -1,86 +1,16 @@
import { ResourceField, FieldType } from './config';
export interface EnumOption {
key: string;
value: string;
}
/**
* This file contains application-specific overrides and configuration
* for the generic Admin Panel.
*/
export interface FieldOverride {
displayFormat?: string;
displayField?: string | string[];
display?: boolean;
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 {
fields?: Record<string, FieldOverride>;
pagination?: 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
*/
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(
schema: any,
resourceName: string,
@@ -63,26 +43,12 @@ function parseSchemaFields(
configuration: Record<string, any> = {}
): 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 || {};
console.log('inside parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
for (const [key, prop] of Object.entries(properties) as [string, any]) {
// Resolve oneOf/anyOf by merging all branch properties
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 type = mapOpenApiType(prop);
const override = overrides[key];
// Explicitly skip 'id' as it's the primary key and handled elsewhere
@@ -91,12 +57,12 @@ function parseSchemaFields(
fields[key] = {
type,
label:
resolvedProp.title ||
prop.title ||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
required: required.includes(key),
options: resolvedProp.enum,
options: prop.enum,
readOnly:
resolvedProp.readOnly ||
prop.readOnly ||
key === "created_at" ||
key === "updated_at",
...override,
@@ -105,36 +71,20 @@ function parseSchemaFields(
// STRICT RELATION DETECTION
// A field is a relation ONLY if its schema object (or items schema)
// exactly matches a schema that is defined as a resource.
let targetSchema = resolvedProp;
if (type === "array" && resolvedProp.items) {
targetSchema = resolvedProp.items;
let targetSchema = prop;
if (type === "array" && prop.items) {
targetSchema = prop.items;
}
// Check if this schema object is registered as a resource
const relation = schemaToResourceMap.get(targetSchema);
if (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)
if (fields[key].type === "object" && resolvedProp.properties && !relation) {
console.log('recursive configuration...', configuration['accounts']['referenceOptions'])
fields[key].schema = parseSchemaFields(resolvedProp, resourceName, schemaToResourceMap, configuration);
if (fields[key].type === "object" && prop.properties && !relation) {
fields[key].schema = parseSchemaFields(prop, resourceName, schemaToResourceMap, configuration);
}
}
@@ -145,7 +95,6 @@ function parseSchemaFields(
* Scans paths to identify resources and their basic configuration
*/
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.
// Dereferencing preserves object identity for $ref targets.
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 pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
console.log('before parseSchemaFields configuration...', configuration['accounts']['referenceOptions'])
const fields = parseSchemaFields(schema, name, schemaToResourceMap, configuration);
const resourceOverride = configuration[name] || {};
const fo = resourceOverride.filterOptions || {};
resources.push({
name,
label: schema.title || label,
pluralLabel: pluralLabel,
endpoint: listPath,
primaryKey: "id",
primaryKey: "id", // Strict default, no heuristics
fields,
pagination: resourceOverride.pagination,
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
const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "")
// @ts-ignore
@@ -243,7 +174,6 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
baseUrl: serverBaseUrl,
authBaseUrl: authBaseUrl,
resources,
enums,
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 }));
}

48
src/AppTheme.tsx Normal file
View File

@@ -0,0 +1,48 @@
import * as React from "react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { getDesignTokens } from "./shared-theme/themePrimitives";
import { inputsCustomizations } from "./shared-theme/customizations/inputs";
import { dataDisplayCustomizations } from "./shared-theme/customizations/dataDisplay";
import { feedbackCustomizations } from "./shared-theme/customizations/feedback";
import { navigationCustomizations } from "./shared-theme/customizations/navigation";
import { surfacesCustomizations } from "./shared-theme/customizations/surfaces";
export const ColorModeContext = React.createContext({
toggleColorMode: () => {},
mode: "light" as "light" | "dark",
});
export default function AppTheme({ children }: { children: React.ReactNode }) {
const [mode, setMode] = React.useState<"light" | "dark">("light");
const colorMode = React.useMemo(
() => ({
toggleColorMode: () => {
setMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
},
mode,
}),
[mode]
);
const theme = React.useMemo(
() =>
createTheme({
...getDesignTokens(mode),
components: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
},
}),
[mode]
);
return (
<ColorModeContext.Provider value={colorMode}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ColorModeContext.Provider>
);
}

View File

@@ -3,236 +3,17 @@ import {
Box,
Container,
CircularProgress,
Alert,
TextField,
Paper,
Autocomplete,
Button
Alert
} from "@mui/material";
import DashboardView from "./components/Dashboard";
import {
DashboardState,
DashboardStateSetters,
DashboardFlow,
} from "./components/Dashboard";
import ConfigurableDashboard from "./components/Dashboard";
import { configuration } from "./dashboard-config";
import {
useReport,
prepareReport,
} from "./features/report";
import { useResourceByName } from "../react-openapi";
function formatSnapshotDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
import { useDashboardData } from "./features/dashboard";
export default function Dashboard() {
const [state, setState] = React.useState<DashboardState>({
flow: "outflows",
periodType: "rolling",
selectedPeriodId: null,
selectedGroupKey: null,
comparison: false,
});
const { data, isLoading, error } = useDashboardData();
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]);
const [appliedTags, setAppliedTags] = React.useState<string[]>([]);
const [payeeInput, setPayeeInput] = React.useState<string[]>([]);
const [tagsInput, setTagsInput] = React.useState<string[]>([]);
const [loadedPayees, setLoadedPayees] = 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({
snapshot_id: selectedSnapshotId ?? undefined,
periods: ["daily", "weekly", "monthly", "all"],
flow: state.flow,
payee: appliedPayees.length > 0 ? appliedPayees : undefined,
tags: appliedTags.length > 0 ? appliedTags : undefined,
});
React.useEffect(() => {
if (report.data) {
setLoadedPayees(prev => {
const pSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
if (t.payee?.name) pSet.add(t.payee.name);
});
});
});
});
return Array.from(pSet).sort();
});
setLoadedTags(prev => {
const tSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
});
});
});
});
return Array.from(tSet).sort();
});
}
}, [report.data]);
const toggleFlow =
React.useCallback(() => {
setState((prev) => ({
...prev,
flow:
prev.flow ===
"outflows"
? "inflows"
: "outflows",
selectedGroupKey:
null,
selectedPeriodId:
null,
}));
}, []);
const setFlow =
React.useCallback(
(
flow: DashboardFlow
) => {
setState((prev) => ({
...prev,
flow,
selectedGroupKey:
null,
selectedPeriodId:
null,
}));
},
[]
);
const togglePeriodType =
React.useCallback(() => {
setState((prev) => ({
...prev,
periodType:
prev.periodType ===
"rolling"
? "calendar"
: "rolling",
}));
}, []);
const toggleComparison =
React.useCallback(() => {
setState((prev) => ({
...prev,
comparison:
!prev.comparison,
}));
}, []);
const setSelectedPeriodId =
React.useCallback(
(
selectedPeriodId: DashboardState["selectedPeriodId"]
) => {
setState((prev) => ({
...prev,
selectedPeriodId,
}));
},
[]
);
const setSelectedGroupKey =
React.useCallback(
(
selectedGroupKey: DashboardState["selectedGroupKey"]
) => {
setState((prev) => ({
...prev,
selectedGroupKey,
}));
},
[]
);
const stateSetters: DashboardStateSetters =
React.useMemo(
() => ({
toggleFlow,
setFlow,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
}),
[
toggleFlow,
setFlow,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
]
);
const isLoading = report.isLoading;
const error = report.error;
if (isLoading && !report.data) {
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress />
@@ -248,92 +29,14 @@ export default function Dashboard() {
);
}
if (!report.data) {
if (!data) {
return null;
}
const data = prepareReport(report.data);
return (
<Box>
<Container>
<Paper
sx={{
mt: 4,
p: 2,
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: { xs: "stretch", sm: "flex-end" },
borderRadius: 4,
mb: -2 // pull up to be closer to the dashboard container below
}}
elevation={0}
variant="outlined"
>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Payee
</Box>
<Autocomplete
multiple
freeSolo
options={loadedPayees}
value={payeeInput}
onChange={(_, val) => setPayeeInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add payees..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Tags
</Box>
<Autocomplete
multiple
freeSolo
options={loadedTags}
value={tagsInput}
onChange={(_, val) => setTagsInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add tags..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</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
variant="contained"
size="large"
onClick={() => {
setAppliedPayees(payeeInput);
setAppliedTags(tagsInput);
}}
disabled={isLoading}
sx={{ height: 40, borderRadius: 2 }}
>
Apply
</Button>
</Paper>
</Container>
<DashboardView
config={configuration}
data={data}
state={state}
stateSetters={stateSetters}
isFetching={report.isFetching}
/>
</Box>
<ConfigurableDashboard
config={configuration}
data={data}
/>
);
}

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

@@ -20,7 +20,7 @@ import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../react-auth";
import { ColorModeContext } from "./shared-theme/AppTheme";
import { ColorModeContext } from "./AppTheme";
interface HeaderProps {
routerMapping: {
@@ -91,32 +91,6 @@ export default function Header({
<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 */}
{isAuthenticated ? (
<>

View File

@@ -1,180 +1,70 @@
import * as React from "react";
import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { Box, Typography, Button, Container, Stack } from "@mui/material";
import { useNavigate } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/Dashboard";
import SyncIcon from "@mui/icons-material/Sync";
import BarChartIcon from "@mui/icons-material/BarChart";
import SettingsIcon from "@mui/icons-material/Settings";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { useAuth } from "../react-auth";
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() {
const navigate = useNavigate();
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 (
<Box
sx={{
minHeight: "calc(100vh - 64px)",
width: "100%",
minHeight: "calc(100vh - 64px)", // accounting for header
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
position: "relative",
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
top: "-15%",
left: "-8%",
width: "45%",
height: "55%",
background: "radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)",
top: "-20%",
left: "-10%",
width: "50%",
height: "60%",
background: "radial-gradient(circle, rgba(99,102,241,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0,
},
"&::after": {
content: '""',
position: "absolute",
bottom: "-15%",
right: "-8%",
width: "45%",
height: "55%",
background: "radial-gradient(circle, rgba(236,72,153,0.1) 0%, transparent 70%)",
bottom: "-20%",
right: "-10%",
width: "50%",
height: "60%",
background: "radial-gradient(circle, rgba(236,72,153,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0,
},
}}
>
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1, flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", py: 6 }}>
<Box
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1 }}>
<Stack
spacing={4}
alignItems="center"
textAlign="center"
sx={{
textAlign: "center",
mb: 6,
p: { xs: 4, md: 8 },
backdropFilter: "blur(20px)",
backgroundColor: (theme) =>
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.03)" : "rgba(255, 255, 255, 0.6)",
border: "1px solid",
borderColor: "divider",
borderRadius: 4,
boxShadow: (theme) =>
theme.palette.mode === "dark"
? "0 8px 32px 0 rgba(0, 0, 0, 0.37)"
: "0 8px 32px 0 rgba(31, 38, 135, 0.07)",
}}
>
<Typography
variant="h1"
sx={{
fontWeight: 800,
fontSize: { xs: "2.5rem", sm: "3.5rem", md: "5rem" },
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 50%, #f59e0b 100%)",
fontSize: { xs: "3rem", md: "5rem" },
background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: "-0.03em",
mb: 2,
}}
>
@@ -182,20 +72,14 @@ export default function Home() {
</Typography>
<Typography
variant="h6"
variant="h5"
color="text.secondary"
sx={{
maxWidth: 580,
mx: "auto",
lineHeight: 1.7,
fontWeight: 400,
fontSize: { xs: "1rem", md: "1.15rem" },
}}
sx={{ maxWidth: "600px", lineHeight: 1.6 }}
>
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>
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: "center", flexWrap: "wrap" }}>
<Box mt={4}>
<Button
variant="contained"
size="large"
@@ -203,44 +87,21 @@ export default function Home() {
onClick={() => navigate("/dashboard")}
sx={{
px: 4,
py: 1.4,
py: 1.5,
borderRadius: "50px",
fontWeight: 700,
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)",
transition: "transform 0.2s ease, box-shadow 0.2s",
fontWeight: "bold",
background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
transition: "transform 0.2s ease-in-out, box-shadow 0.2s",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.35)}`,
transform: "translateY(-3px)",
boxShadow: "0 8px 20px rgba(236,72,153,0.4)",
},
}}
>
Enter Dashboard
</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>
<Grid container spacing={3}>
{features.map((f) => (
<Grid key={f.title} size={{ xs: 12, sm: 6, md: 3 }}>
<FeatureCard {...f} />
</Grid>
))}
</Grid>
</Stack>
</Container>
</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,61 +1,53 @@
import * as React from "react";
import {
ReportData,
GroupKey,
} from "../../features/report";
export type DashboardFlow = "outflows" | "inflows";
export type DashboardMode = "expense" | "income";
export type DashboardPeriodType = "rolling" | "calendar";
export type DashboardSelectedPeriodId = string | null;
export interface DashboardState {
flow: DashboardFlow;
mode: DashboardMode;
periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean;
}
export interface DashboardStateSetters {
setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleFlow: () => void;
togglePeriodType: () => void;
toggleComparison: () => void;
}
export interface DashboardSection {
id: string;
title: string;
component: React.ComponentType<any>;
title?: string;
summary?: string;
component: React.ComponentType<any>;
dataKey: string;
settings?: Record<string, any>;
isList?: boolean;
style?: {
size?: number;
[key: string]: any;
};
}
export interface ColorDefinition {
primary: string;
background?: string;
text?: string;
}
export interface ThemeAwarePalette {
light: ColorDefinition;
dark: ColorDefinition;
}
export interface DashboardConfig {
sections: DashboardSection[];
style?: {
palette?: Record<DashboardMode, ThemeAwarePalette>;
};
}
export interface DashboardViewProps {
export interface DashboardProps {
config: DashboardConfig;
data: ReportData;
state: DashboardState;
stateSetters: DashboardStateSetters;
isFetching: boolean;
}
export interface ColorScheme {
primary: string;
surface: string;
text: string;
}
export interface ComponentProps extends DashboardSection {
reportData: ReportData;
state: DashboardState;
stateSetters: DashboardStateSetters;
isFetching: boolean;
colorScheme: ColorScheme;
data: any;
toggleMode: () => void;
togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void;
toggleComparison: () => void;
}

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import DashboardView from "./Dashboard.view";
import { DashboardProps, DashboardState } from "./Dashboard.models";
export default function Dashboard(props: DashboardProps) {
const [state, setState] = React.useState<DashboardState>({
mode: "expense",
periodType: "rolling",
selectedPeriodId: null,
comparison: false,
});
const toggleMode = () => {
setState(prev => ({
...prev,
mode: prev.mode === "expense" ? "income" : "expense",
}));
};
const togglePeriodType = () => {
setState(prev => ({
...prev,
periodType: prev.periodType === "rolling" ? "calendar" : "rolling",
}));
};
const toggleComparison = () => {
setState(prev => ({
...prev,
comparison: !prev.comparison,
}));
};
const setSelectedPeriodId = (selectedPeriodId: typeof state.selectedPeriodId) => {
setState(prev => ({ ...prev, selectedPeriodId }));
};
return (
<DashboardView
{...props}
state={state}
setState={setState}
toggleMode={toggleMode}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
/>
);
}

View File

@@ -3,99 +3,161 @@ import {
Box,
Container,
Grid,
Typography,
ToggleButton,
ToggleButtonGroup,
Button
ToggleButtonGroup
} from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { DashboardViewProps } from "./Dashboard.models";
import { DashboardProps, DashboardState } from "./Dashboard.models";
interface ViewProps extends DashboardProps {
state: DashboardState;
setState: React.Dispatch<React.SetStateAction<DashboardState>>;
}
export default function DashboardView({
config,
data,
state,
stateSetters,
isFetching,
}: DashboardViewProps) {
setState,
toggleMode,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
}: ViewProps) {
const theme = useTheme();
const themeMode = theme.palette.mode;
const { mode, periodType, comparison, selectedPeriodId } = state;
const {
flow,
selectedGroupKey,
} = state;
// Resolve colors with fallbacks
const colors = React.useMemo(() => {
const palette = config.style?.palette?.[mode];
const modeColors = palette ? palette[themeMode] : null;
const colorScheme = flow === "outflows" ? theme.palette.flows.outflows : theme.palette.flows.inflows;
if (modeColors) {
return {
primary: modeColors.primary,
light: modeColors.background || alpha(modeColors.primary, 0.1),
text: modeColors.text || (themeMode === 'light' ? theme.palette.text.primary : '#fff')
};
}
// Fallback to standard theme colors
const themeColor = mode === 'expense' ? theme.palette.error : theme.palette.success;
return {
primary: themeColor.main,
light: alpha(themeColor.main, themeMode === 'light' ? 0.08 : 0.15),
text: themeColor.main
};
}, [config.style?.palette, mode, themeMode, theme.palette]);
return (
<Container
sx={{
mt: 4,
mb: 4,
background: `linear-gradient(180deg, ${alpha(colorScheme.primary, theme.palette.mode === "dark" ? 0.06 : 0.04)} 0%, transparent 100%)`,
background: `linear-gradient(180deg, ${colors.light} 0%, transparent 100%)`,
borderRadius: 4,
p: 2,
transition: "background 0.3s ease",
transition: 'background 0.3s ease'
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
mb: 3,
}}
>
<Box sx={{ display: "flex", justifyContent: "center", mb: 3 }}>
<ToggleButtonGroup
value={flow}
value={mode}
exclusive
onChange={stateSetters.toggleFlow}
onChange={toggleMode}
sx={{
borderRadius: 3,
overflow: "hidden",
"& .MuiToggleButton-root": {
px: 3,
textTransform: "none",
color: "text.secondary",
color: "text.secondary"
},
"&.Mui-selected": {
bgcolor: colorScheme.primary,
bgcolor: colors.primary,
color: "white",
borderColor: colorScheme.primary,
borderColor: colors.primary
},
}}
>
<ToggleButton value="outflows">Outflows</ToggleButton>
<ToggleButton value="inflows">Inflows</ToggleButton>
<ToggleButton value="expense">Expenses</ToggleButton>
<ToggleButton value="income">Income</ToggleButton>
</ToggleButtonGroup>
{selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && (
<Button
size="small"
sx={{ mt: 1, textTransform: "none" }}
onClick={() => stateSetters.setSelectedGroupKey(null)}
>
Clear Drill-down
</Button>
)}
</Box>
<Grid container spacing={4}>
{config.sections.map((section) => {
const Component = section.component;
return (
<Grid key={section.id} size={12}>
<Component
{...section}
<Grid key={section.id} size={section.style?.size || 12 as any}>
{section.title && !section.isList && (
<Box sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
{section.title}
</Typography>
</Box>
)}
reportData={data}
{section.isList ? (
<Box>
{section.title && (
<Box sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
{section.title}
</Typography>
</Box>
)}
<Grid container spacing={2}>
{(data[section.dataKey || ""] || []).map((item: any, idx: number) => (
<Grid key={idx} size={{ xs: 12, sm: 6, md: 2.4 }}>
<Component
{...section.settings}
header={item.payeeName || item.name}
progressAmount={item.amount}
totalAmount={data.totalAmount}
accentColor={colors.primary}
colorScheme={colors}
state={state}
stateSetters={stateSetters}
isFetching={isFetching}
// State management
mode={mode}
colorScheme={colorScheme}
/>
periodType={periodType}
comparison={comparison}
selectedPeriodId={selectedPeriodId}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
/>
</Grid>
))}
</Grid>
</Box>
) : (
<Component
{...section.settings}
header={section.title}
summary={section.summary}
data={data[mode][section.dataKey]}
title={section.title}
accentColor={colors.primary}
colorScheme={colors}
// State management
mode={mode}
periodType={periodType}
comparison={comparison}
selectedPeriodId={selectedPeriodId}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
/>
)}
</Grid>
);
})}

View File

@@ -1,2 +1,2 @@
export { default } from "./Dashboard.view";
export { default } from "./Dashboard";
export * from "./Dashboard.models";

View File

@@ -1,73 +0,0 @@
import { ReportData } from "../../features/report";
import {
mergeBucketPeriods,
getAmount,
PeriodKey,
} from "../report.helpers";
import { ChartDataPoint } from "./HistoryChart.models";
// ─── Tab → PeriodKey ─────────────────────────────────────────
const TAB_TO_KEY: Record<string, PeriodKey> = {
Daily: "daily",
Weekly: "weekly",
Monthly: "monthly",
"All Time": "all",
};
export function tabToKey(tab: string): PeriodKey {
return TAB_TO_KEY[tab] ?? "all";
}
// ─── Comparison ──────────────────────────────────────────────
function attachComparison(
points: ChartDataPoint[],
key: PeriodKey
): ChartDataPoint[] {
const getCompareIndex = (i: number) => {
if (key === "daily") return i - 7;
if (key === "weekly") return i - 4;
if (key === "monthly") return i - 12;
return -1;
};
return points.map((p, i) => {
const ci = getCompareIndex(i);
return {
...p,
compare:
ci >= 0 && points[ci]
? {
id: points[ci].id,
label: points[ci].label,
amount: points[ci].amount,
}
: undefined,
};
});
}
// ─── Main adapter ────────────────────────────────────────────
export function buildChartData(
reportData: ReportData,
key: PeriodKey,
flow: "outflows" | "inflows",
comparison: boolean
): ChartDataPoint[] {
const merged = mergeBucketPeriods(reportData.buckets, key);
let points: ChartDataPoint[] = merged.map((p) => ({
id: p.id,
label: p.label,
amount: getAmount(p),
}));
if (comparison) {
points = attachComparison(points, key);
}
return points;
}

View File

@@ -1,6 +1,11 @@
import {
DashboardMode,
DashboardPeriodType,
DashboardSelectedPeriodId
} from "../Dashboard";
export interface _ChartDataPoint {
id: string;
label: string;
amount: number;
highlighted?: boolean;
}
@@ -8,3 +13,33 @@ export interface _ChartDataPoint {
export interface ChartDataPoint extends _ChartDataPoint {
compare?: _ChartDataPoint;
}
export interface ChartData {
weekly?: Record<string, ChartDataPoint[]>;
monthly?: Record<string, ChartDataPoint[]>;
// yearly?: Record<string, ChartDataPoint[]>;
// fyly?: Record<string, ChartDataPoint[]>;
// full?: Record<string, ChartDataPoint[]>;
}
export interface HistoryChartProps {
header: string;
summary?: string;
tabs: string[];
data: ChartData;
colorScheme: {
primary: string;
light: string;
text: string;
};
// State management
mode: DashboardMode;
periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId;
comparison: boolean;
togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void;
toggleComparison: () => void;
}

View File

@@ -1,21 +0,0 @@
import * as React from "react";
import { ComponentProps } from "../Dashboard";
import { ChartDataPoint } from "./HistoryChart.models";
export interface HistoryChartProps extends ComponentProps {
settings: {
tabs: string[];
};
}
export interface HistoryChartViewProps extends HistoryChartProps {
activeTab: string;
setActiveTab: (v: string) => void;
currentData: ChartDataPoint[];
visibleData: ChartDataPoint[];
maxAmount: number;
visibleCount: number;
startIndex: number;
setStartIndex: React.Dispatch<React.SetStateAction<number>>;
activeDataKey: string;
}

View File

@@ -1,84 +1,45 @@
import * as React from "react";
import { ChartDataPoint, HistoryChartProps, ChartData } from "./HistoryChart.models";
import HistoryChartView from "./HistoryChart.view";
import { buildChartData, tabToKey } from "./HistoryChart.adapter";
import { HistoryChartProps } from "./HistoryChart.props";
export default function HistoryChart(props: HistoryChartProps) {
const {
settings,
reportData,
state,
stateSetters,
isFetching,
} = props;
const { flow, comparison, selectedPeriodId } = state;
const { setSelectedPeriodId } = stateSetters;
const { tabs } = settings;
const { tabs, data, mode, periodType, comparison } = props;
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
const [startIndex, setStartIndex] = React.useState(0);
const activeDataKey = tabToKey(activeTab);
const activeDataKey = activeTab.toLowerCase() as keyof ChartData;
const currentData = React.useMemo(() => {
return buildChartData(reportData, activeDataKey, flow, comparison);
}, [reportData, activeDataKey, flow, comparison]);
let rawData: ChartDataPoint[] = [];
const section = data[activeDataKey];
rawData = section?.[periodType] || [];
const currentData = rawData;
const maxAmount =
currentData.length > 0
? Math.max(
...currentData.flatMap((d) =>
comparison
? [d.amount, ...(d.compare ? [d.compare.amount] : [])]
: [d.amount]
comparison ? [d.amount, d.compare?.amount ?? 0] : [d.amount]
),
1
)
: 1;
const visibleCountMap = {
daily: 7,
weekly: 6,
monthly: 4,
all: 4,
};
const visibleCount = visibleCountMap[activeDataKey] ?? 4;
const visibleCountMap = { daily: 7, weekly: 6, monthly: 4 };
// const visibleCountMap = { daily: 7, weekly: 6, monthly: 4, yearly: 4, fyly: 4, full: 4 };
const visibleCount = visibleCountMap[activeDataKey];
const total = currentData.length;
const clampedStartIndex = Math.min(
startIndex,
Math.max(total - visibleCount, 0)
);
React.useEffect(() => {
if (startIndex !== clampedStartIndex) {
setStartIndex(clampedStartIndex);
}
}, [startIndex, clampedStartIndex]);
const clampedStartIndex = Math.min(startIndex, Math.max(total - visibleCount, 0));
const visibleData = currentData.slice(
clampedStartIndex,
clampedStartIndex + visibleCount
);
React.useEffect(() => {
setSelectedPeriodId(null);
}, [activeTab]);
React.useEffect(() => {
if (
selectedPeriodId &&
!visibleData.some((p) => p.id === selectedPeriodId)
) {
setSelectedPeriodId(null);
}
}, [visibleData, selectedPeriodId]);
return (
<HistoryChartView
{...props}
@@ -88,7 +49,7 @@ export default function HistoryChart(props: HistoryChartProps) {
visibleData={visibleData}
maxAmount={maxAmount}
visibleCount={visibleCount}
startIndex={clampedStartIndex}
startIndex={startIndex}
setStartIndex={setStartIndex}
activeDataKey={activeDataKey}
/>

View File

@@ -25,3 +25,19 @@ export const formatDisplay = (
return `${formatShort(base)} (${sign}${formatShort(Math.abs(diff))})`;
};
export const formatLabel = (label: string, type: string) => {
if (type === "monthly") return label;
if (type === "weekly") {
const parts = label.split(" - ");
if (parts.length === 2) {
const [start, end] = parts;
const startDay = start.split(" ")[0];
const [endDay, month] = end.split(" ");
return `${startDay}${endDay} ${month}`;
}
}
return label;
};

View File

@@ -11,60 +11,68 @@ import IconButton from "@mui/material/IconButton";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import {
HistoryChartViewProps,
} from "./HistoryChart.props";
import { formatDisplay } from "./HistoryChart.utils";
ChartDataPoint,
HistoryChartProps,
} from "./HistoryChart.models";
import { formatDisplay, formatLabel } from "./HistoryChart.utils";
export default function HistoryChartView({
title,
summary,
settings,
interface ViewProps extends HistoryChartProps {
activeTab: string;
setActiveTab: (v: string) => void;
currentData: ChartDataPoint[];
visibleData: ChartDataPoint[];
maxAmount: number;
visibleCount: number;
startIndex: number;
setStartIndex: React.Dispatch<React.SetStateAction<number>>;
activeDataKey: string;
}
state,
stateSetters,
isFetching,
export default function HistoryChartView(props: ViewProps) {
const {
header,
summary,
tabs,
colorScheme,
colorScheme,
// State management
mode,
periodType,
selectedPeriodId,
comparison,
activeTab,
setActiveTab,
currentData,
visibleData,
maxAmount,
visibleCount,
startIndex,
setStartIndex,
activeDataKey,
}: HistoryChartViewProps) {
togglePeriodType,
setSelectedPeriodId,
toggleComparison,
const { flow, periodType, selectedPeriodId, comparison } = state;
const { togglePeriodType, setSelectedPeriodId, toggleComparison } = stateSetters;
// HistoryChart state management
activeTab,
setActiveTab,
currentData,
visibleData,
maxAmount,
visibleCount,
startIndex,
setStartIndex,
activeDataKey,
} = props;
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const total = currentData.length;
const maxStartIndex = Math.max(total - visibleCount, 0);
const clampedStartIndex = Math.min(startIndex, maxStartIndex);
const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => {
if (newTab !== null) setActiveTab(newTab);
};
const canGoLeft = clampedStartIndex > 0;
const canGoRight = clampedStartIndex < maxStartIndex;
const canGoLeft = startIndex > 0;
const canGoRight = startIndex + visibleCount < currentData.length;
const handlePrev = () => {
if (!canGoLeft) return;
setStartIndex((prev) => Math.max(prev - visibleCount, 0));
if (canGoLeft) setStartIndex((prev) => prev - visibleCount);
};
const handleNext = () => {
if (!canGoRight) return;
setStartIndex((prev) => {
const next = prev + visibleCount;
return Math.min(next, maxStartIndex);
});
if (canGoRight) setStartIndex((prev) => prev + visibleCount);
};
return (
@@ -76,14 +84,12 @@ export default function HistoryChartView({
boxShadow: "none",
border: "1px solid",
borderColor: "divider",
bgcolor: isDark ? "background.paper" : colorScheme.surface,
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
bgcolor: isDark ? "background.paper" : colorScheme.light,
transition: 'background-color 0.3s ease, border-color 0.3s ease'
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>
{title}
<Typography variant="h6" fontWeight={700} gutterBottom sx={{ color: isDark ? 'text.primary' : colorScheme.text }}>
{header}
</Typography>
{summary && (
@@ -93,17 +99,19 @@ export default function HistoryChartView({
)}
<ToggleButtonGroup value={activeTab} exclusive onChange={handleTabChange} fullWidth sx={{ mb: 4 }}>
{settings.tabs.map((tab) => (
{tabs.map((tab) => (
<ToggleButton key={tab} value={tab}>
{tab}
</ToggleButton>
))}
</ToggleButtonGroup>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 3 }}>
<ToggleButtonGroup value={periodType} exclusive onChange={togglePeriodType} size="small">
<ToggleButton value="rolling">Rolling</ToggleButton>
<ToggleButton value="calendar">Calendar</ToggleButton>
<ToggleButton value="calendar" disabled={activeDataKey === "daily"}>
Calendar
</ToggleButton>
</ToggleButtonGroup>
<ToggleButton
@@ -111,6 +119,22 @@ export default function HistoryChartView({
selected={comparison}
onChange={toggleComparison}
size="small"
sx={{
textTransform: "none",
borderRadius: 2,
px: 2,
color: "text.secondary",
border: "1px solid",
borderColor: "divider",
"&.Mui-selected": {
color: "white",
bgcolor: "success.main",
borderColor: "success.main"
},
"&.Mui-selected:hover": {
bgcolor: "success.dark"
}
}}
>
Compare
</ToggleButton>
@@ -119,7 +143,19 @@ export default function HistoryChartView({
{currentData.length > 0 ? (
<Box sx={{ position: "relative", mt: 4 }}>
{canGoLeft && (
<IconButton onClick={handlePrev} size="small" sx={{ position: "absolute", left: 0, top: "50%" }}>
<IconButton
onClick={handlePrev}
size="small"
sx={{
position: "absolute",
left: 0,
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
bgcolor: "background.paper",
boxShadow: 1
}}
>
<ChevronLeftIcon fontSize="small" />
</IconButton>
)}
@@ -130,67 +166,92 @@ export default function HistoryChartView({
const compareHeight = comparison
? ((point.compare?.amount ?? 0) / maxAmount) * 100
: 0;
const labelHeight = Math.max(currentHeight, compareHeight);
const isSelected = selectedPeriodId === point.id;
const display = formatDisplay(point, activeDataKey, comparison);
const display = formatDisplay(point, activeTab.toLowerCase(), comparison);
return (
<Box
key={point.id}
onClick={() =>
setSelectedPeriodId(isSelected ? null : point.id)
}
onClick={() => setSelectedPeriodId(isSelected ? null : point.id)}
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
cursor: "pointer",
height: "100%"
justifyContent: "flex-end",
height: "100%",
cursor: "pointer"
}}
>
<Box sx={{ display: "flex", alignItems: "flex-end", gap: 1, height: "100%" }}>
<Box sx={{ display: "flex", alignItems: "flex-end", gap: comparison ? 1 : 0.5, height: "100%", position: "relative" }}>
<Typography
variant="caption"
sx={{
position: "absolute",
bottom: `${labelHeight}%`,
left: "50%",
transform: "translate(-50%, -6px)",
fontSize: "0.65rem",
whiteSpace: "nowrap",
pointerEvents: "none",
color: 'text.secondary',
fontWeight: 600
}}
>
{isSelected ? `SELECTED: ${display}` : display}
</Typography>
{comparison && (
<Box
sx={{
width: 8,
height: `${compareHeight}%`,
bgcolor: alpha(colorScheme.primary, 0.4),
borderRadius: "4px 4px 0 0"
}}
/>
<Box sx={{ width: 8, height: `${compareHeight}%`, bgcolor: isDark ? alpha(colorScheme.primary, 0.3) : alpha(colorScheme.primary, 0.4), borderRadius: '4px 4px 0 0' }} />
)}
<Box
sx={{
width: 12,
width: comparison ? 10 : 16,
height: `${currentHeight}%`,
bgcolor: isSelected ? "warning.main" : colorScheme.primary,
borderRadius: "4px 4px 0 0"
bgcolor: point.highlighted ? colorScheme.primary : isDark ? alpha(colorScheme.primary, 0.8) : alpha(colorScheme.primary, 0.9),
borderRadius: '4px 4px 0 0',
boxShadow: point.highlighted ? `0 0 10px ${alpha(colorScheme.primary, 0.5)}` : 'none'
}}
/>
</Box>
<Typography variant="caption">
{point.label}
</Typography>
{comparison && point.compare && (
<Typography variant="caption" color="text.secondary">
{point.compare.label}
<Box sx={{ mt: 1.5, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", lineHeight: 1.1 }}>
<Typography variant="caption" sx={{ fontSize: "0.7rem", opacity: 0.8, color: 'text.primary', fontWeight: 500 }}>
{formatLabel(point.id, activeDataKey)}
</Typography>
)}
<Typography variant="caption">
{display}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: "0.65rem",
color: "text.disabled",
visibility: comparison && point.compare && activeDataKey !== "daily" ? "visible" : "hidden"
}}
>
{point.compare ? formatLabel(point.compare.id, activeDataKey) : "placeholder"}
</Typography>
</Box>
</Box>
);
})}
</Box>
{canGoRight && (
<IconButton onClick={handleNext} size="small" sx={{ position: "absolute", right: 0, top: "50%" }}>
<IconButton
onClick={handleNext}
size="small"
sx={{
position: "absolute",
right: 0,
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
bgcolor: "background.paper",
boxShadow: 1
}}
>
<ChevronRightIcon fontSize="small" />
</IconButton>
)}

View File

@@ -1,31 +0,0 @@
import { ReportData, GroupKey } from "../../features/report";
import {
formatCurrency,
extractFilteredTransactions,
} from "../report.helpers";
import { LatestItem } from "./LatestItems.models";
// ─── Main adapter ────────────────────────────────────────────
export function buildLatestItems(
reportData: ReportData,
selectedPeriodId: string | null | undefined,
selectedGroupKey: GroupKey | null | undefined,
flow: "outflows" | "inflows"
): LatestItem[] {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
return txns
.sort(
(a, b) =>
new Date(b.occurred_at).getTime() -
new Date(a.occurred_at).getTime()
)
.map((t, index) => ({
id: index + 1,
title: t.payee.name,
subtitle: t.tags.map((tag) => tag.name).join(", "),
amount: formatCurrency(t.amount),
timeAgo: new Date(t.occurred_at).toLocaleDateString("en-IN"),
}));
}

View File

@@ -1,7 +1,18 @@
import * as React from "react";
export interface LatestItem {
id: string | number;
icon: React.ReactNode;
iconBgColor?: string;
title: string;
subtitle: string;
amount: string;
timeAgo: string;
}
export interface LatestItemsListProps {
title?: string;
items: LatestItem[];
onViewAll?: () => void;
accentColor: string;
}

View File

@@ -1,10 +0,0 @@
import { ComponentProps } from "../Dashboard";
import { LatestItem } from "./LatestItems.models";
export interface LatestItemsProps extends ComponentProps {}
export interface LatestItemsViewProps extends LatestItemsProps {
items: LatestItem[];
canExpand: boolean;
onExpand: () => void;
}

View File

@@ -1,40 +1,112 @@
import * as React from "react";
import { buildLatestItems } from "./LatestItems.adapter";
import LatestItemsView from "./LatestItems.view";
import { LatestItemsProps } from "./LatestItems.props";
import {
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
Button,
} from "@mui/material";
export default function LatestItems(props: LatestItemsProps) {
const {
reportData,
state,
stateSetters,
isFetching,
} = props;
export interface LatestItem {
id: string | number;
icon: React.ReactNode;
iconBgColor?: string;
title: string;
subtitle: string;
amount: string;
timeAgo: string;
}
const { flow, selectedPeriodId, selectedGroupKey } = state;
const [visibleCount, setVisibleCount] = React.useState(5);
// Reset count when flow changes to start clean
React.useEffect(() => {
setVisibleCount(5);
}, [flow]);
const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, flow);
}, [reportData, selectedPeriodId, selectedGroupKey, flow]);
const visibleItems = React.useMemo(() => {
return allItems.slice(0, visibleCount);
}, [allItems, visibleCount]);
const canExpand = visibleCount < allItems.length;
export interface LatestItemsListProps {
title?: string;
items: LatestItem[];
onViewAll?: () => void;
accentColor: any;
}
export default function LatestItems({
title = "Recent Transactions",
items,
onViewAll,
accentColor,
}: LatestItemsListProps) {
return (
<LatestItemsView
{...props}
items={visibleItems}
canExpand={canExpand}
onExpand={() => setVisibleCount((prev) => prev + 5)}
/>
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}>
{/* Header */}
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold">
{title}
</Typography>
{onViewAll && (
<Button
variant="text"
color="inherit"
size="small"
sx={{ textTransform: "none", color: "text.secondary", fontWeight: "medium" }}
onClick={onViewAll}
>
view all
</Button>
)}
</Box>
{/* List */}
<List disablePadding>
{items.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== items.length - 1 ? 1 : 0,
borderRadius: 3,
"&:hover": { bgcolor: "action.hover" },
transition: "background-color 0.2s ease",
}}
>
<ListItemAvatar>
<Avatar
variant="rounded"
sx={{
bgcolor: `${accentColor}22`,
color: "inherit",
width: 48,
height: 48,
borderRadius: 3,
mr: 2,
}}
>
{item.icon}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" fontWeight={600} color="text.primary">
{item.title}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{item.subtitle}
</Typography>
}
/>
<Box sx={{ textAlign: "right" }}>
<Typography variant="subtitle1" fontWeight={700} color="text.primary">
{item.amount}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{item.timeAgo}
</Typography>
</Box>
</ListItem>
))}
</List>
</Box>
);
}

View File

@@ -1,93 +1,6 @@
import * as React from "react";
import {
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
IconButton,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { LatestItemsViewProps } from "./LatestItems.props";
import LatestItemsListView from "./LatestItems.view";
import { LatestItemsListProps } from "./LatestItems.models";
export default function LatestItemsView({
items,
title,
canExpand,
onExpand,
isFetching,
colorScheme,
}: LatestItemsViewProps) {
const accentColor = colorScheme?.primary || "";
return (
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2, opacity: isFetching ? 0.6 : 1, transition: "opacity 0.3s ease", pointerEvents: isFetching ? "none" : "auto" }}>
<Box sx={{ mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold">
{title}
</Typography>
</Box>
<List disablePadding>
{items.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== items.length - 1 ? 1 : 0,
borderRadius: 3,
"&:hover": { bgcolor: "action.hover" },
}}
>
<ListItemAvatar>
<Avatar
variant="rounded"
sx={{
bgcolor: alpha(accentColor, 0.13),
width: 48,
height: 48,
borderRadius: 3,
mr: 2,
}}
/>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" fontWeight={600}>
{item.title}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{item.subtitle}
</Typography>
}
/>
<Box sx={{ textAlign: "right" }}>
<Typography variant="subtitle1" fontWeight={700}>
{item.amount}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.timeAgo}
</Typography>
</Box>
</ListItem>
))}
{canExpand && (
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<IconButton size="small" onClick={onExpand}>
<ExpandMoreIcon />
</IconButton>
</Box>
)}
</List>
</Box>
);
export default function LatestItemsList(props: LatestItemsListProps) {
return <LatestItemsListView {...props} />;
}

View File

@@ -0,0 +1,8 @@
export interface ProgressCardProps {
header: string;
summary?: string;
progressAmount: number;
totalAmount: number;
colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
compact?: boolean;
}

View File

@@ -1,14 +0,0 @@
import { ComponentProps } from "../Dashboard";
export interface ProgressCardProps extends ComponentProps {
settings: {
compact: boolean;
};
}
export interface ProgressCardViewProps extends ProgressCardProps {
progressAmount: number;
totalAmount: number;
selected: boolean;
onClick: () => void;
}

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import ProgressCardView from "./ProgressCard.view";
import { ProgressCardProps } from "./ProgressCard.models";
import { getPercentage, formatCurrency } from "./ProgressCard.utils";
export default function ProgressCard(props: ProgressCardProps) {
const { progressAmount, totalAmount, compact = false } = props;
const percentage = getPercentage(progressAmount, totalAmount);
const formattedProgress = formatCurrency(progressAmount);
const formattedTotal = formatCurrency(totalAmount);
return (
<ProgressCardView
{...props}
percentage={percentage}
formattedProgress={formattedProgress}
formattedTotal={formattedTotal}
compact={compact}
/>
);
}

View File

@@ -0,0 +1,15 @@
export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
};
export const formatCurrency = (val: number) => {
const absVal = Math.abs(val);
if (absVal >= 100000) {
return `${(val / 100000).toFixed(2)}L`;
}
if (absVal >= 1000) {
return `${(val / 1000).toFixed(2)}k`;
}
return `${val.toFixed(2)}`;
};

View File

@@ -8,79 +8,77 @@ import {
linearProgressClasses
} from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { getPercentage, formatCurrency } from "../report.helpers";
import { ProgressCardViewProps } from "./ProgressCard.props";
import { ProgressCardProps } from "./ProgressCard.models";
interface ViewProps extends ProgressCardProps {
percentage: number;
formattedProgress: string;
formattedTotal: string;
}
export default function ProgressCardView({
title,
settings,
isFetching,
colorScheme,
progressAmount,
totalAmount,
selected,
onClick,
}: ProgressCardViewProps) {
header,
colorTheme = "info",
percentage,
formattedProgress,
formattedTotal,
compact = false,
}: ViewProps) {
const theme = useTheme();
const percentage = getPercentage(progressAmount, totalAmount);
const formattedProgress = formatCurrency(progressAmount);
const formattedTotal = formatCurrency(totalAmount);
const isDark = theme.palette.mode === "dark";
return (
<Paper
elevation={settings.compact ? 2 : 4}
onClick={onClick}
elevation={compact ? 2 : 4}
sx={{
width: "100%",
p: settings.compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
borderRadius: settings.compact ? 3 : 4,
transform: selected ? "scale(1.02)" : "scale(1)",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
bgcolor: colorScheme.surface,
color: colorScheme.text,
p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
borderRadius: compact ? 3 : 4,
background: (theme) => {
const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main;
const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light;
return isDark
? `linear-gradient(135deg, ${alpha(baseColor, 0.9)} 0%, ${alpha(baseColor, 0.3)} 100%)`
: `linear-gradient(135deg, ${baseColor} 0%, ${lightColor} 100%)`;
},
color: "#fff",
display: "flex",
flexDirection: "column",
alignItems: settings.compact ? "flex-start" : "center",
alignItems: compact ? "flex-start" : "center",
justifyContent: "center",
position: "relative",
overflow: "hidden",
border: selected
? `2px solid ${colorScheme.primary}`
: "1px solid",
borderColor: selected ? colorScheme.primary : "divider",
boxShadow: "none",
opacity: isFetching ? 0.6 : 1,
pointerEvents: isFetching ? "none" : "auto",
border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none",
boxShadow: (theme) =>
`0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
isDark
? "rgba(0,0,0,0.5)"
: theme.palette[colorTheme]?.main || theme.palette.primary.main
}`,
}}
>
<Typography
variant={settings.compact ? "body2" : "subtitle1"}
fontWeight={700}
sx={{
opacity: 0.95,
mb: settings.compact ? 1.5 : 2,
width: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
<Typography
variant={compact ? "body2" : "subtitle1"}
fontWeight={700}
sx={{
opacity: 0.95,
mb: compact ? 1.5 : 2,
width: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
letterSpacing: 0.5,
textShadow: isDark ? '0 1px 2px rgba(0,0,0,0.3)' : 'none'
}}
>
{title}
{header}
</Typography>
<Box sx={{ mb: settings.compact ? 2 : 3, width: "100%" }}>
<Box sx={{ mb: compact ? 2 : 3, width: '100%' }}>
<Typography
variant={settings.compact ? "h5" : "h3"}
variant={compact ? "h5" : "h3"}
fontWeight={900}
sx={{
mb: 0.5,
lineHeight: 1.2,
}}
sx={{ mb: 0.5, lineHeight: 1.2, textShadow: isDark ? '0 2px 4px rgba(0,0,0,0.3)' : 'none' }}
>
{formattedProgress}
</Typography>
@@ -88,38 +86,38 @@ export default function ProgressCardView({
<Divider
sx={{
my: 1,
borderColor: "divider",
borderColor: "rgba(255,255,255,0.25)",
width: "100%",
}}
/>
<Typography
variant={settings.compact ? "caption" : "body2"}
variant={compact ? "caption" : "body2"}
sx={{
opacity: 0.85,
fontWeight: 500,
display: "block",
color: alpha(colorScheme.text, 0.85),
color: "rgba(255,255,255,0.9)"
}}
>
of {formattedTotal}
</Typography>
</Box>
<Box sx={{ width: "100%", mt: "auto" }}>
<Box sx={{ width: "100%", mt: 'auto' }}>
<LinearProgress
variant="determinate"
value={percentage}
sx={{
height: settings.compact ? 6 : 10,
height: compact ? 6 : 10,
borderRadius: 5,
[`&.${linearProgressClasses.colorPrimary}`]: {
backgroundColor: alpha(theme.palette.divider, 0.5),
backgroundColor: "rgba(0, 0, 0, 0.25)",
},
[`& .${linearProgressClasses.bar}`]: {
borderRadius: 5,
backgroundColor: colorScheme.primary,
boxShadow: `0 0 8px ${alpha(colorScheme.primary, 0.4)}`,
backgroundColor: "#fff",
boxShadow: '0 0 8px rgba(255,255,255,0.4)'
},
}}
/>

View File

@@ -1,31 +0,0 @@
import { GroupKey, ReportData } from "../../features/report";
import {
extractFilteredTransactions,
aggregateTransactions,
} from "../report.helpers";
export interface PayeeItem {
name: string;
amount: number;
}
export function extractTopPayees(
reportData: ReportData,
flow: "outflows" | "inflows",
selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: PayeeItem[]; total: number } {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
const { items, total } = aggregateTransactions(txns, (txn) => {
if (txn.payee && txn.payee.name) {
return [txn.payee.name];
}
return [];
});
return {
items,
total,
};
}

View File

@@ -1,83 +0,0 @@
import * as React from "react";
import { Box, Paper, Typography } from "@mui/material";
import ProgressCardView from "./ProgressCard.view";
import { extractTopPayees } from "./TopPayees.adapter";
import { ProgressCardProps } from "./ProgressCard.props";
export default function TopPayees(props: ProgressCardProps) {
const {
title,
reportData,
state,
stateSetters,
isFetching,
} = props
const { flow, selectedPeriodId, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
const { items, total } = React.useMemo(() => {
return extractTopPayees(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
return (
<Paper
sx={{
p: { xs: 2.5, sm: 4 },
borderRadius: 4,
width: "100%",
boxShadow: "none",
border: "1px solid",
borderColor: "divider",
bgcolor: "background.paper",
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>
{title}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)",
},
gap: 2,
}}
>
{items.map((item) => {
const isSelected = !!selectedGroupKey?.payee?.includes(item.name);
return (
<ProgressCardView
{...props}
key={item.name}
title={item.name}
progressAmount={item.amount}
totalAmount={total}
selected={isSelected}
onClick={() => {
if (setSelectedGroupKey) {
let newKey = selectedGroupKey ? { ...selectedGroupKey } : {};
if (isSelected) {
delete newKey.payee;
} else {
newKey.payee = [item.name];
}
setSelectedGroupKey(Object.keys(newKey).length ? newKey : null);
}
}}
/>
);
})}
</Box>
</Paper>
);
}

View File

@@ -1,31 +0,0 @@
import { ReportData, GroupKey } from "../../features/report";
import {
extractFilteredTransactions,
aggregateTransactions,
} from "../report.helpers";
export interface TagItem {
tag: string;
amount: number;
}
export function extractTopTags(
reportData: ReportData,
flow: "outflows" | "inflows",
selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: TagItem[]; total: number } {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
const { items, total } = aggregateTransactions(txns, (txn) => {
if (txn.tags && txn.tags.length > 0) {
return txn.tags.map((t) => (typeof t === "string" ? t : t.name));
}
return ["Untagged"];
});
return {
items: items.map((item) => ({ tag: item.name, amount: item.amount })),
total,
};
}

View File

@@ -1,83 +0,0 @@
import * as React from "react";
import { Box, Paper, Typography } from "@mui/material";
import ProgressCardView from "./ProgressCard.view";
import { extractTopTags } from "./TopTags.adapter";
import { ProgressCardProps } from "./ProgressCard.props";
export default function TopTags(props: ProgressCardProps) {
const {
title,
reportData,
state,
stateSetters,
isFetching,
} = props
const { flow, selectedPeriodId, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
const { items, total } = React.useMemo(() => {
return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
return (
<Paper
sx={{
p: { xs: 2.5, sm: 4 },
borderRadius: 4,
width: "100%",
boxShadow: "none",
border: "1px solid",
borderColor: "divider",
bgcolor: "background.paper",
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>
{title}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)",
},
gap: 2,
}}
>
{items.map((item) => {
const isSelected = !!selectedGroupKey?.tags?.includes(item.tag);
return (
<ProgressCardView
{...props}
key={item.tag}
title={item.tag}
progressAmount={item.amount}
totalAmount={total}
selected={isSelected}
onClick={() => {
if (setSelectedGroupKey) {
let newKey = selectedGroupKey ? { ...selectedGroupKey } : {};
if (isSelected) {
delete newKey.tags;
} else {
newKey.tags = [item.tag];
}
setSelectedGroupKey(Object.keys(newKey).length ? newKey : null);
}
}}
/>
);
})}
</Box>
</Paper>
);
}

View File

@@ -1,2 +1,2 @@
export { default } from "./ProgressCard.view";
export * from "./ProgressCard.props";
export { default } from "./ProgressCard";
export * from "./ProgressCard.models";

View File

@@ -1,230 +0,0 @@
import {
ReportPeriod,
ReportBucket,
GroupKey,
PeriodType,
ReportData,
Transaction,
} from "../features/report";
// ─── Types ────────────────────────────────────────────────────
export type PeriodKey = PeriodType;
export type DecoratedPeriod = ReportPeriod & {
id: string;
label: string;
};
// ─── Period helpers ───────────────────────────────────────────
const PREFIX_TO_KEY: Record<string, PeriodKey> = {
D: "daily",
W: "weekly",
M: "monthly",
ALL: "all",
};
/**
* Derive the period key from a decorated-period id.
* E.g. `"W:2026-04-28_2026-05-04"` → `"weekly"`
*/
export function periodIdToKey(periodId: string): PeriodKey {
const prefix = periodId.split(":")[0];
return PREFIX_TO_KEY[prefix] ?? "all";
}
// ─── Metric helpers ───────────────────────────────────────────
export function getAmount(period: ReportPeriod): number {
return period.metric.sum;
}
function mergeMetric(a: ReportPeriod["metric"], b: ReportPeriod["metric"]) {
const sum = a.sum + b.sum;
const count = a.count + b.count;
return {
...a,
sum,
count,
average: count > 0 ? sum / count : 0,
transactions:
a.transactions || b.transactions
? [...(a.transactions || []), ...(b.transactions || [])]
: undefined,
};
}
/**
* Merge periods with the same id across all buckets, summing
* their metrics and concatenating transactions.
*
* Returns sorted by start date ascending.
*/
export function mergeBucketPeriods(
buckets: ReportBucket[],
key: PeriodKey
): DecoratedPeriod[] {
const map = new Map<string, DecoratedPeriod>();
for (const bucket of buckets) {
const periods = (bucket.periods[key] || []) as DecoratedPeriod[];
for (const p of periods) {
const existing = map.get(p.id);
if (!existing) {
map.set(p.id, {
...p,
metric: { ...p.metric },
});
} else {
map.set(p.id, {
...existing,
metric: mergeMetric(existing.metric, p.metric),
});
}
}
}
return Array.from(map.values()).sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()
);
}
// ─── Formatting ───────────────────────────────────────────────
export const formatCurrency = (val: number) => {
const absVal = Math.abs(val);
if (absVal >= 100000) {
return `${(val / 100000).toFixed(2)}L`;
}
if (absVal >= 1000) {
return `${(val / 1000).toFixed(2)}k`;
}
return `${val.toFixed(2)}`;
};
export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
};
// ─── Group filtering ──────────────────────────────────────────
/**
* Check if a bucket's group_key matches the selected GroupKey.
* Every dimension present in `selected` must exist in the bucket
* and contain all the selected values.
*/
export function matchesGroupKey(
bucket: ReportBucket,
selected: GroupKey
): boolean {
for (const [dim, values] of Object.entries(selected)) {
const bucketValues = bucket.group_key[dim];
if (!bucketValues) return false;
if (!(values as string[]).every((v) => bucketValues.includes(v)))
return false;
}
return true;
}
/**
* Return only buckets matching the selected group key,
* or all buckets if no selection.
*/
export function filterBuckets(
buckets: ReportBucket[],
selectedGroupKey: GroupKey | null
): ReportBucket[] {
if (!selectedGroupKey) return buckets;
return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey));
}
export function extractFilteredTransactions(
reportData: ReportData,
selectedPeriodId: string | null | undefined,
selectedGroupKey: GroupKey | null | undefined
): Transaction[] {
let txns: Transaction[] = [];
if (selectedPeriodId) {
const key = periodIdToKey(selectedPeriodId);
const periods = mergeBucketPeriods(reportData.buckets, key);
const selected = periods.find((p) => p.id === selectedPeriodId);
txns = selected?.metric.transactions || [];
} else {
const periods = mergeBucketPeriods(reportData.buckets, "all");
if (periods.length > 0) {
const period = periods.reduce((latest, p) =>
new Date(p.start).getTime() > new Date(latest.start).getTime()
? p
: latest
, periods[0]);
txns = period?.metric.transactions || [];
}
}
if (selectedGroupKey) {
txns = txns.filter((txn) => {
let match = true;
if (selectedGroupKey.tags && selectedGroupKey.tags.length > 0) {
if (!txn.tags) {
match = false;
} else {
const txnTags = txn.tags.map((t: any) =>
typeof t === "string" ? t : t.name
);
if (
!selectedGroupKey.tags.every((selectedTag) =>
txnTags.includes(selectedTag)
)
) {
match = false;
}
}
}
if (match && selectedGroupKey.payee && selectedGroupKey.payee.length > 0) {
if (!txn.payee || !txn.payee.name) {
match = false;
} else {
if (!selectedGroupKey.payee.includes(txn.payee.name)) {
match = false;
}
}
}
return match;
});
}
return txns;
}
export function aggregateTransactions(
transactions: Transaction[],
keyExtractor: (txn: Transaction) => string[],
limit = 4
): { items: { name: string; amount: number }[]; total: number } {
const map = new Map<string, number>();
for (const txn of transactions) {
const keys = keyExtractor(txn);
for (const key of keys) {
map.set(key, (map.get(key) || 0) + txn.amount);
}
}
const items = Array.from(map.entries()).map(([name, amount]) => ({
name,
amount,
}));
items.sort((a, b) => b.amount - a.amount);
const top = items.slice(0, limit);
const total = top.reduce((sum, item) => sum + item.amount, 0);
return { items: top, total };
}

View File

@@ -1,8 +1,7 @@
import HistoryChart from "./components/HistoryChart";
import ProgressCard from "./components/ProgressCard";
import LatestItems from "./components/LatestItems";
import { DashboardConfig } from "./components/Dashboard";
import TopTags from "./components/ProgressCard/TopTags";
import TopPayees from "./components/ProgressCard/TopPayees";
export const configuration: DashboardConfig = {
sections: [
@@ -11,30 +10,64 @@ export const configuration: DashboardConfig = {
title: "Breakdown",
summary: "Interactive chronological tracking",
component: HistoryChart,
dataKey: "chartData",
settings: {
tabs: ["Weekly", "Monthly"],
// tabs: ["Weekly", "Monthly", "Yearly", "Financial Year", "All Time"],
},
},
{
id: "top-categories",
title: 'Top Categories',
component: TopTags,
settings: {
compact: true,
style: {
size: 12,
},
},
{
id: "top-payees",
title: 'Top Payees',
component: TopPayees,
component: ProgressCard,
dataKey: "topPayees",
isList: true,
settings: {
compact: true,
},
style: {
size: 12,
},
},
{
id: "items",
title: 'Recent Transactions',
component: LatestItems,
},
// {
// id: "latest",
// title: 'Recent Transactions',
// component: LatestItems,
// dataKey: "latest",
// style: {
// size: 12,
// },
// },
],
style: {
palette: {
expense: {
light: {
primary: "#d32f2f",
background: "#fdecea",
text: "#b71c1c"
},
dark: {
primary: "#f44336",
background: "rgba(244, 67, 54, 0.15)",
text: "#ffcdd2"
}
},
income: {
light: {
primary: "#2e7d32",
background: "#e8f5e9",
text: "#1b5e20"
},
dark: {
primary: "#4caf50",
background: "rgba(76, 175, 80, 0.15)",
text: "#c8e6c9"
}
}
}
}
};

View File

@@ -0,0 +1,191 @@
import * as React from "react";
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
import { LatestItem } from "../../components/LatestItems";
import {
ChartData,
ChartDataPoint,
} from "../../components/HistoryChart";
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
sx: { color: "#388e3c" }
});
type ReportBucket = any;
export function mapToLatestItems(
items: any[],
type: "expense" | "income"
): LatestItem[] {
const isValid = (amt: number) =>
type === "expense" ? amt < 0 : amt > 0;
return items
.filter((item: any) => isValid(Number(item.amount) || 0))
.slice(0, 5)
.map((exp: any, index: number) => {
const time = new Date(exp.occurred_at).getTime();
const diffDays = Math.floor(
Math.abs(Date.now() - time) / (1000 * 60 * 60 * 24)
);
return {
id: exp.id || index,
icon: DEFAULT_ICON,
iconBgColor:
type === "expense" ? "#ffebee" : "#e8f5e9",
title: exp.payee.name,
subtitle: exp.account.name,
amount: `Rs ${Math.abs(exp.amount || 0)}`,
timeAgo: diffDays === 0 ? "Today" : `${diffDays} days ago`
};
});
}
const sumBucket = (bucket: ReportBucket, flow: "expenses" | "incomes") =>
bucket.groups.reduce(
(acc: number, g: any) => acc + (g?.[flow]?.sum || 0),
0
);
const toLabel = (start: string, end: string, type: "weekly" | "monthly" | "yearly" | "fyly" | "full") => {
const s = new Date(start);
const e = new Date(end);
if (type === "monthly") {
return s.toLocaleString("default", { month: "short" });
}
return `${s.getDate()}${e.getDate()} ${e.toLocaleString("default", {
month: "short",
})}`;
};
const getWeekOfMonth = (date: Date) => {
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
return Math.ceil((date.getDate() + firstDay.getDay()) / 7);
};
const findCompareBucket = (
current: ReportBucket,
buckets: ReportBucket[],
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
): ReportBucket | undefined => {
const start = new Date(current.start);
if (type === "monthly") {
const targetYear = start.getFullYear() - 1;
const targetMonth = start.getMonth();
return buckets.find(b => {
const d = new Date(b.start);
return (
d.getFullYear() === targetYear &&
d.getMonth() === targetMonth
);
});
}
if (type === "weekly") {
const weekIndex = getWeekOfMonth(start); // you must define this
const target = new Date(start);
target.setMonth(target.getMonth() - 1);
return buckets.find(b => {
const d = new Date(b.start);
return (
d.getFullYear() === target.getFullYear() &&
d.getMonth() === target.getMonth() &&
getWeekOfMonth(d) === weekIndex
);
});
}
return undefined;
};
const toPoints = (
buckets: ReportBucket[],
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
flow: "expenses" | "incomes"
): ChartDataPoint[] => {
return buckets.map((b) => {
const amount = sumBucket(b, flow);
const prev = findCompareBucket(b, buckets, type);
return {
id: toLabel(b.start, b.end, type),
amount,
compare: prev
? {
id: toLabel(prev.start, prev.end, type),
amount: sumBucket(prev, flow),
}
: undefined,
};
});
};
export function mapReportToDashboard(
weekly: ReportBucket[],
monthly: ReportBucket[],
payeeBuckets: ReportBucket[],
type: "expense" | "income"
) {
const flow = type === "expense" ? "expenses" : "incomes";
const chartData: ChartData = {
weekly: {
rolling: toPoints(weekly, "weekly", flow),
calendar: toPoints(weekly, "weekly", flow),
},
monthly: {
rolling: toPoints(monthly, "monthly", flow),
calendar: toPoints(monthly, "monthly", flow),
},
// yearly: {
// rolling: toPoints(yearly, "yearly", flow),
// calendar: toPoints(yearly, "yearly", flow),
// },
//
// fyly: {
// rolling: toPoints(fyly, "fyly", flow),
// calendar: toPoints(fyly, "fyly", flow),
// },
//
// full: {
// rolling: toPoints(full, "full", flow),
// calendar: toPoints(full, "full", flow),
// },
};
const totalAmount = weekly.reduce(
(acc, b) => acc + sumBucket(b, flow),
0
);
const payeeMap: Record<string, number> = {};
const sourceForPayees = (payeeBuckets && payeeBuckets.length > 0) ? payeeBuckets : weekly;
for (const b of sourceForPayees) {
for (const g of b.groups) {
const key = g.group_key || "Unknown";
const amt = g?.[flow]?.sum || 0;
payeeMap[key] = (payeeMap[key] || 0) + amt;
}
}
const topPayees = Object.entries(payeeMap)
// .filter(([name]) => name !== "Unknown")
.map(([payeeName, amount]) => ({ payeeName, amount }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
return {
chartData,
totalAmount,
topPayees,
};
}

View File

@@ -0,0 +1,3 @@
export {
useDashboardData
} from './useDashboardData'

View File

@@ -0,0 +1,44 @@
import { useReport } from "../report";
import { mapReportToDashboard } from "./dashboard.mapper";
export function useDashboardData() {
// Fetch reports for aggregation
const weeklyReport = useReport({ period: "weekly", rolling: true });
const monthlyReport = useReport({ period: "monthly", rolling: true });
const payeeReport = useReport({ period: "full", rolling: true, group_by: ["payee"] });
const isLoading =
weeklyReport.isLoading ||
monthlyReport.isLoading ||
payeeReport.isLoading;
const error =
weeklyReport.error ||
monthlyReport.error ||
payeeReport.error;
const aggregatedData = {
expense: weeklyReport.data?.data && monthlyReport.data?.data && payeeReport.data?.data
? mapReportToDashboard(
(weeklyReport.data.data as any).buckets,
(monthlyReport.data.data as any).buckets,
(payeeReport.data.data as any).buckets,
"expense"
)
: null,
income: weeklyReport.data?.data && monthlyReport.data?.data && payeeReport.data?.data
? mapReportToDashboard(
(weeklyReport.data.data as any).buckets,
(monthlyReport.data.data as any).buckets,
(payeeReport.data.data as any).buckets,
"income"
)
: null,
}
return {
data: aggregatedData,
isLoading,
error,
};
}

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

@@ -3,12 +3,10 @@ export {
} from './useReport'
export type {
Transaction,
PeriodData,
PeriodGroup,
Period,
ReportData,
ReportBucket,
ReportPeriod,
ReportQuery,
GroupKey,
PeriodType,
} from './report.models'
export {
prepareReport

View File

@@ -1,112 +1,61 @@
export interface Payor {
id?: string;
name: string;
username: string;
email: string;
}
export interface Payee {
type: "merchant" | "person" | "transfer" | "other";
name: string;
}
export interface Account {
id: string;
name: string;
number: string;
type: "cash" | "bank" | "credit_card" | "wallet" | "other";
currency: string;
is_active?: boolean;
}
export interface Tag {
id: string;
name: string;
icon: string;
parent_id?: string | null;
description: string;
}
export interface Transaction {
id: string;
payor: Payor;
payee: Payee;
amount: number;
account: Account;
tags: Tag[];
occurred_at: string;
created_at: string;
payor: Payor;
payee: Payee;
amount: number;
account: Account;
tags: Tag[]
occurred_at: Date
}
// -----------------------------
// Metrics
// -----------------------------
export interface ReportMetric {
export interface _PeriodData {
sum: number;
count: number;
average: number;
transactions?: Transaction[];
txns: Transaction[];
}
// -----------------------------
// Period
// -----------------------------
export type PeriodType = "daily" | "weekly" | "monthly" | "all";
export interface ReportPeriod {
start: string;
end: string;
metric: ReportMetric;
export interface PeriodData extends _PeriodData {
compare?: _PeriodData;
}
// -----------------------------
// Group (bucket)
// -----------------------------
export type GroupKey = {
[dimension: string]: string[];
};
export interface ReportBucket {
group_key: GroupKey;
periods: {
daily?: ReportPeriod[];
weekly?: ReportPeriod[];
monthly?: ReportPeriod[];
all?: ReportPeriod[];
};
export interface PeriodGroup {
group_key: string[];
expenses: PeriodData[];
incomes: PeriodData[];
}
// -----------------------------
// Report Query
// -----------------------------
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 Period {
id: string;
label: string;
start: Date;
end: Date;
groups: PeriodGroup[];
}
// -----------------------------
// Final Report
// -----------------------------
export interface ReportData {
snapshot_id?: string | null;
flow?: "inflows" | "outflows" | null;
periods: PeriodType[];
tags?: string[] | null;
payee?: string[] | null;
buckets: ReportBucket[];
query: ReportQuery;
period: "weekly" | "monthly" | "yearly" | "fyly" | "full";
rolling?: boolean;
report_date?: string;
group_by?: ("payee" | "tags")[];
ignore_self?: boolean;
buckets: Period[];
}

View File

@@ -1,8 +1,4 @@
import {
ReportData,
ReportPeriod,
PeriodType,
} from "./report.models";
import { ReportData } from "./report.models";
/* ---------- ID BUILDING ---------- */
@@ -14,7 +10,7 @@ function formatDate(d: Date): string {
}
function buildPeriodId(
type: PeriodType,
type: ReportData["period"],
start: Date,
end: Date
): string {
@@ -22,14 +18,16 @@ function buildPeriodId(
const e = formatDate(end);
switch (type) {
case "daily":
return `D:${s}_${e}`;
case "weekly":
return `W:${s}_${e}`;
case "monthly":
return `M:${s}_${e}`;
case "all":
return `ALL:${s}_${e}`;
case "yearly":
return `Y:${s}_${e}`;
case "fyly":
return `FY:${s}_${e}`;
case "full":
return `FULL:${s}_${e}`;
default:
return `${s}_${e}`;
}
@@ -59,23 +57,45 @@ const yearFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: "UTC",
});
function sameMonth(a: Date, b: Date) {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth()
);
}
function buildLabel(
type: PeriodType,
type: ReportData["period"],
start: Date,
end: Date
): string {
switch (type) {
case "daily":
return dayFmt.format(start);
case "weekly": {
const sDay = start.getUTCDate();
const m = monthFmt.format(start);
return `${sDay} ${m}`;
}
case "weekly":
if (sameMonth(start, end)) {
const sDay = start.getUTCDate();
const eDay = end.getUTCDate();
const m = monthFmt.format(start);
return `${sDay} ${m} - ${eDay} ${m}`;
}
return `${dayFmt.format(start)} - ${dayFmt.format(end)}`;
case "monthly":
return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
if (sameMonth(start, end)) {
return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
}
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
case "yearly":
return yearFmt.format(start);
case "fyly": {
const startY = start.getUTCFullYear();
const endY = end.getUTCFullYear();
return `FY ${startY}${String(endY).slice(-2)}`;
}
case "full":
return `${monthFmt.format(start)} ${yearFmt.format(start)} - ${monthFmt.format(end)} ${yearFmt.format(end)}`;
default:
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
@@ -84,34 +104,13 @@ function buildLabel(
/* ---------- MAIN ---------- */
function decoratePeriods(
type: PeriodType,
periods: ReportPeriod[]
): (ReportPeriod & { id: string; label: string })[] {
return periods.map((p) => ({
...p,
id: buildPeriodId(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
label: buildLabel(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
}));
}
export function prepareReport(reportData: ReportData): ReportData {
return {
...reportData,
buckets: reportData.buckets.map((bucket) => {
const newPeriods: typeof bucket.periods = {};
for (const type of reportData.periods) {
const arr = bucket.periods[type];
if (arr) {
newPeriods[type] = decoratePeriods(type, arr);
}
}
return {
...bucket,
periods: newPeriods,
};
}),
buckets: reportData.buckets.map((p) => ({
...p,
id: buildPeriodId(reportData.period, p.start, p.end),
label: buildLabel(reportData.period, p.start, p.end),
})),
};
}
}

View File

@@ -1,21 +1,18 @@
import { useResourceByName } from "../../../react-openapi";
export interface ReportParams {
snapshot_id?: string;
periods?: ("daily" | "weekly" | "monthly" | "all")[];
flow?: "inflows" | "outflows";
payee?: string[];
tags?: string[];
period: "weekly" | "monthly" | "yearly" | "fyly" | "full";
rolling?: boolean;
report_date?: string;
group_by?: ("payee" | "tags")[];
ignore_self?: boolean;
}
export function useReport(params: ReportParams) {
const { useRead } = useResourceByName("reports");
return useRead(
params.snapshot_id ? params.snapshot_id : "latest",
{
...params,
periods: params.periods,
}
);
}
const { useList } = useResourceByName("reports");
if (params.group_by) {
// @ts-ignore
params.group_by = params.group_by[0]
}
return useList(params);
}

View File

@@ -12,17 +12,14 @@ import {
} from "@mui/material";
import Home from './Home';
import Dashboard from './Dashboard';
import FetchRequests from './FetchRequests';
import FetchRequestDetail from './FetchRequestDetail';
import ReportSnapshots from './ReportSnapshots';
import { Admin, AppProvider, defaultFieldComponents } from '../react-openapi';
import { Admin, AppProvider } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config';
import { Buffer } from 'buffer';
import process from 'process';
import { AuthProvider } from "../react-auth";
import Header from './Header';
import Footer from './Footer';
import AppTheme from './shared-theme/AppTheme';
import AppTheme from './AppTheme';
window.Buffer = Buffer;
window.process = process;
@@ -36,9 +33,6 @@ const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", component: Home, headerTitle: "Home" },
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" },
{ path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" },
{ path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" },
{ path: "/reports", component: ReportSnapshots, headerTitle: "Reports" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" },
];
@@ -60,7 +54,7 @@ root.render(
path={path}
element={
path.startsWith("/admin") ? (
<Component basePath="/admin" fieldComponents={{ ...defaultFieldComponents }} />
<Component basePath="/admin" />
) : (
<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> = {
expenses: {
filterOptions: {
mode: "client",
fields: ["account", "payee", "tags", "occurred_at", "amount"],
},
fields: {
payee: {
displayFormat: "{name}",
filterType: "autocomplete",
displayField: "name",
},
payor: {
display: false,
displayFormat: "{username}",
displayField: "username",
},
account: {
displayFormat: "{name}",
filterType: "multiselect",
refers: "accounts"
displayField: "name",
},
tags: {
displayFormat: "{icon} {name}",
filterType: "autocomplete",
refers: "tags"
displayField: ["name", "icon"],
},
occurred_at: {
filterType: "date-range",
formatter: (val: string) => {
const date = new Date(val);
const day = date.getDate();
@@ -44,47 +34,15 @@ export const configuration: Record<string, ResourceOverride> = {
return `${day}${suffix(day)} ${month} ${year}`;
}
},
amount: {
filterType: "number-range",
},
created_at: {
display: false
}
},
pagination: true,
},
'fetch-requests': {
fields: {
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,
}
},
reports: {
hidden: true
}
};
export const profileConfiguration = {

View File

@@ -1,103 +1,53 @@
import * as React from "react";
import {
ThemeProvider,
createTheme,
CssBaseline,
Box,
} from "@mui/material";
import * as React from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import type { ThemeOptions } from '@mui/material/styles';
import { inputsCustomizations } from './customizations/inputs';
import { dataDisplayCustomizations } from './customizations/dataDisplay';
import { feedbackCustomizations } from './customizations/feedback';
import { navigationCustomizations } from './customizations/navigation';
import { surfacesCustomizations } from './customizations/surfaces';
import { colorSchemes, typography, shadows, shape } from './themePrimitives';
import { getDesignTokens } from "./themePrimitives";
import { getSemanticColors } from "./themeConfig";
import { inputsCustomizations } from "./customizations/inputs";
import { dataDisplayCustomizations } from "./customizations/dataDisplay";
import { feedbackCustomizations } from "./customizations/feedback";
import { navigationCustomizations } from "./customizations/navigation";
import { surfacesCustomizations } from "./customizations/surfaces";
export type ColorMode = "light" | "dark";
type ColorModeContextValue = {
mode: ColorMode;
setMode: (mode: ColorMode) => void;
toggleColorMode: () => void;
};
export const ColorModeContext =
React.createContext<ColorModeContextValue>({
mode: "light",
setMode: () => {},
toggleColorMode: () => {},
});
type AppThemeProps = {
interface AppThemeProps {
children: React.ReactNode;
defaultMode?: ColorMode;
};
export default function AppTheme({
children,
defaultMode = "light",
}: AppThemeProps) {
const [mode, setMode] =
React.useState<ColorMode>(defaultMode);
const toggleColorMode = React.useCallback(() => {
setMode((prev) =>
prev === "light" ? "dark" : "light"
);
}, []);
const contextValue = React.useMemo(
() => ({
mode,
setMode,
toggleColorMode,
}),
[mode, toggleColorMode]
);
const semantic = React.useMemo(
() => getSemanticColors(mode),
[mode]
);
const theme = React.useMemo(
() =>
createTheme({
...getDesignTokens(mode),
semantic,
components: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
},
}),
[mode, semantic]
);
/**
* This is for the docs site. You can ignore it or remove it.
*/
disableCustomTheme?: boolean;
themeComponents?: ThemeOptions['components'];
}
export default function AppTheme(props: AppThemeProps) {
const { children, disableCustomTheme, themeComponents } = props;
const theme = React.useMemo(() => {
return disableCustomTheme
? {}
: createTheme({
// For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme',
cssVarPrefix: 'template',
},
colorSchemes, // Recently added in v6 for building light & dark mode app, see https://mui.com/material-ui/customization/palette/#color-schemes
typography,
shadows,
shape,
components: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
...themeComponents,
},
});
}, [disableCustomTheme, themeComponents]);
if (disableCustomTheme) {
return <React.Fragment>{children}</React.Fragment>;
}
return (
<ColorModeContext.Provider value={contextValue}>
<ThemeProvider theme={theme}>
<CssBaseline />
<Box
sx={{
"--bg-page": semantic.surface.page,
"--bg-card": semantic.surface.card,
"--bg-elevated": semantic.surface.elevated,
"--border-default": semantic.border.default,
"--border-subtle": semantic.border.subtle,
"--text-primary": semantic.text.primary,
"--text-secondary": semantic.text.secondary,
"--text-muted": semantic.text.muted,
}}
>
{children}
</Box>
</ThemeProvider>
</ColorModeContext.Provider>
<ThemeProvider theme={theme} disableTransitionOnChange>
{children}
</ThemeProvider>
);
}

View File

@@ -0,0 +1,89 @@
import * as React from 'react';
import DarkModeIcon from '@mui/icons-material/DarkModeRounded';
import LightModeIcon from '@mui/icons-material/LightModeRounded';
import Box from '@mui/material/Box';
import IconButton, { IconButtonOwnProps } from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useColorScheme } from '@mui/material/styles';
export default function ColorModeIconDropdown(props: IconButtonOwnProps) {
const { mode, systemMode, setMode } = useColorScheme();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMode = (targetMode: 'system' | 'light' | 'dark') => () => {
setMode(targetMode);
handleClose();
};
if (!mode) {
return (
<Box
data-screenshot="toggle-mode"
sx={(theme) => ({
verticalAlign: 'bottom',
display: 'inline-flex',
width: '2.25rem',
height: '2.25rem',
borderRadius: (theme.vars || theme).shape.borderRadius,
border: '1px solid',
borderColor: (theme.vars || theme).palette.divider,
})}
/>
);
}
const resolvedMode = (systemMode || mode) as 'light' | 'dark';
const icon = {
light: <LightModeIcon />,
dark: <DarkModeIcon />,
}[resolvedMode];
return (
<React.Fragment>
<IconButton
data-screenshot="toggle-mode"
onClick={handleClick}
disableRipple
size="small"
aria-controls={open ? 'color-scheme-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
{...props}
>
{icon}
</IconButton>
<Menu
anchorEl={anchorEl}
id="account-menu"
open={open}
onClose={handleClose}
onClick={handleClose}
slotProps={{
paper: {
variant: 'outlined',
elevation: 0,
sx: {
my: '4px',
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem selected={mode === 'system'} onClick={handleMode('system')}>
System
</MenuItem>
<MenuItem selected={mode === 'light'} onClick={handleMode('light')}>
Light
</MenuItem>
<MenuItem selected={mode === 'dark'} onClick={handleMode('dark')}>
Dark
</MenuItem>
</Menu>
</React.Fragment>
);
}

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { useColorScheme } from '@mui/material/styles';
import MenuItem from '@mui/material/MenuItem';
import Select, { SelectProps } from '@mui/material/Select';
export default function ColorModeSelect(props: SelectProps) {
const { mode, setMode } = useColorScheme();
if (!mode) {
return null;
}
return (
<Select
value={mode}
onChange={(event) =>
setMode(event.target.value as 'system' | 'light' | 'dark')
}
SelectDisplayProps={{
// @ts-ignore
'data-screenshot': 'toggle-mode',
}}
{...props}
>
<MenuItem value="system">System</MenuItem>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
);
}

View File

@@ -14,8 +14,8 @@ export const feedbackCustomizations: Components<Theme> = {
color: orange[500],
},
...theme.applyStyles('dark', {
backgroundColor: alpha(orange[900], 0.35),
border: `1px solid ${alpha(orange[800], 0.3)}`,
backgroundColor: `${alpha(orange[900], 0.5)}`,
border: `1px solid ${alpha(orange[800], 0.5)}`,
}),
}),
},

View File

@@ -125,15 +125,15 @@ export const inputsCustomizations: Components<Theme> = {
backgroundColor: gray[200],
},
...theme.applyStyles('dark', {
backgroundColor: 'hsla(0, 0%, 100%, 0.06)',
borderColor: (theme.vars || theme).palette.divider,
backgroundColor: gray[800],
borderColor: gray[700],
'&:hover': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
borderColor: 'hsla(0, 0%, 100%, 0.15)',
backgroundColor: gray[900],
borderColor: gray[600],
},
'&:active': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
backgroundColor: gray[900],
},
}),
},
@@ -183,12 +183,12 @@ export const inputsCustomizations: Components<Theme> = {
backgroundColor: gray[200],
},
...theme.applyStyles('dark', {
color: 'hsl(0, 0%, 92%)',
color: gray[50],
'&:hover': {
backgroundColor: 'hsla(0, 0%, 100%, 0.08)',
backgroundColor: gray[700],
},
'&:active': {
backgroundColor: 'hsla(0, 0%, 100%, 0.12)',
backgroundColor: alpha(gray[700], 0.7),
},
}),
},
@@ -241,14 +241,14 @@ export const inputsCustomizations: Components<Theme> = {
backgroundColor: gray[200],
},
...theme.applyStyles('dark', {
backgroundColor: 'hsla(0, 0%, 100%, 0.06)',
borderColor: (theme.vars || theme).palette.divider,
backgroundColor: gray[800],
borderColor: gray[700],
'&:hover': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
borderColor: 'hsla(0, 0%, 100%, 0.15)',
backgroundColor: gray[900],
borderColor: gray[600],
},
'&:active': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)',
backgroundColor: gray[900],
},
}),
variants: [
@@ -288,7 +288,7 @@ export const inputsCustomizations: Components<Theme> = {
[`& .${toggleButtonGroupClasses.selected}`]: {
color: '#fff',
},
boxShadow: `0 2px 8px ${alpha(brand[700], 0.3)}`,
boxShadow: `0 4px 16px ${alpha(brand[700], 0.5)}`,
}),
}),
},
@@ -302,7 +302,7 @@ export const inputsCustomizations: Components<Theme> = {
fontWeight: 500,
...theme.applyStyles('dark', {
color: gray[400],
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.25)',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
[`&.${toggleButtonClasses.selected}`]: {
color: brand[300],
},

View File

@@ -49,8 +49,9 @@ export const navigationCustomizations: Components<Theme> = {
},
},
...theme.applyStyles('dark', {
background: (theme.vars || theme).palette.background.paper,
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)',
background: gray[900],
boxShadow:
'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
}),
}),
},
@@ -83,17 +84,17 @@ export const navigationCustomizations: Components<Theme> = {
...theme.applyStyles('dark', {
borderRadius: (theme.vars || theme).shape.borderRadius,
borderColor: (theme.vars || theme).palette.divider,
borderColor: gray[700],
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'inset 0 1px 0 hsla(0, 0%, 100%, 0.05)',
boxShadow: `inset 0 1px 0 1px ${alpha(gray[700], 0.15)}, inset 0 -1px 0 1px hsla(220, 0%, 0%, 0.7)`,
'&:hover': {
borderColor: 'hsla(0, 0%, 100%, 0.15)',
borderColor: alpha(gray[700], 0.7),
backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'none',
},
[`&.${selectClasses.focused}`]: {
outlineOffset: 0,
borderColor: 'hsl(210, 55%, 55%)',
borderColor: gray[900],
},
'&:before, &:after': {
display: 'none',
@@ -107,7 +108,7 @@ export const navigationCustomizations: Components<Theme> = {
display: 'flex',
alignItems: 'center',
'&:focus-visible': {
backgroundColor: (theme.vars || theme).palette.background.default,
backgroundColor: gray[900],
},
}),
}),
@@ -150,7 +151,6 @@ export const navigationCustomizations: Components<Theme> = {
styleOverrides: {
paper: ({ theme }) => ({
backgroundColor: (theme.vars || theme).palette.background.default,
borderRight: `1px solid ${(theme.vars || theme).palette.divider}`,
}),
},
},
@@ -204,8 +204,8 @@ export const navigationCustomizations: Components<Theme> = {
...theme.applyStyles('dark', {
':hover': {
color: (theme.vars || theme).palette.text.primary,
backgroundColor: alpha((theme.vars || theme).palette.common.white, 0.08),
borderColor: (theme.vars || theme).palette.divider,
backgroundColor: gray[800],
borderColor: gray[700],
},
[`&.${tabClasses.selected}`]: {
color: '#fff',

View File

@@ -40,7 +40,7 @@ export const surfacesCustomizations: Components<Theme> = {
'&:hover': { backgroundColor: gray[50] },
'&:focus-visible': { backgroundColor: 'transparent' },
...theme.applyStyles('dark', {
'&:hover': { backgroundColor: alpha(theme.palette.common.white, 0.06) },
'&:hover': { backgroundColor: gray[800] },
}),
}),
},
@@ -67,7 +67,7 @@ export const surfacesCustomizations: Components<Theme> = {
border: `1px solid ${(theme.vars || theme).palette.divider}`,
boxShadow: 'none',
...theme.applyStyles('dark', {
backgroundColor: (theme.vars || theme).palette.background.paper,
backgroundColor: gray[800],
}),
variants: [
{
@@ -79,7 +79,7 @@ export const surfacesCustomizations: Components<Theme> = {
boxShadow: 'none',
background: 'hsl(0, 0%, 100%)',
...theme.applyStyles('dark', {
background: alpha((theme.vars || theme).palette.background.paper, 0.6),
background: alpha(gray[900], 0.4),
}),
},
},

View File

@@ -1,72 +0,0 @@
import { gray } from "./themePrimitives";
import { alpha } from "@mui/material/styles";
declare module "@mui/material/styles" {
interface Theme {
semantic: SemanticColors;
}
interface ThemeOptions {
semantic?: SemanticColors;
}
}
export type SemanticColorMode = "light" | "dark";
export interface SemanticColors {
surface: {
page: string;
card: string;
elevated: string;
};
border: {
default: string;
subtle: string;
};
text: {
primary: string;
secondary: string;
muted: string;
};
}
const darkBg = 'hsl(0, 0%, 9%)';
const darkPaper = 'hsl(0, 0%, 14%)';
const darkElevated = 'hsl(0, 0%, 19%)';
export function getSemanticColors(mode: SemanticColorMode): SemanticColors {
if (mode === "dark") {
return {
surface: {
page: darkBg,
card: darkPaper,
elevated: darkElevated,
},
border: {
default: 'hsla(0, 0%, 100%, 0.08)',
subtle: 'hsla(0, 0%, 100%, 0.04)',
},
text: {
primary: 'hsl(0, 0%, 92%)',
secondary: 'hsl(0, 0%, 60%)',
muted: 'hsl(0, 0%, 45%)',
},
};
}
return {
surface: {
page: "hsl(0, 0%, 99%)",
card: "hsl(220, 35%, 97%)",
elevated: gray[100],
},
border: {
default: alpha(gray[300], 0.4),
subtle: alpha(gray[200], 0.3),
},
text: {
primary: gray[800],
secondary: gray[600],
muted: gray[500],
},
};
}

View File

@@ -23,10 +23,6 @@ declare module '@mui/material/styles' {
interface Palette {
baseShadow: string;
flows: {
outflows: { primary: string; surface: string; text: string };
inflows: { primary: string; surface: string; text: string };
};
}
}
@@ -56,9 +52,7 @@ export const gray = {
500: 'hsl(220, 20%, 42%)',
600: 'hsl(220, 20%, 35%)',
700: 'hsl(220, 20%, 25%)',
750: 'hsl(220, 20%, 18%)',
800: 'hsl(220, 30%, 6%)',
850: 'hsl(220, 22%, 11%)',
900: 'hsl(220, 35%, 3%)',
};
@@ -101,14 +95,10 @@ export const red = {
900: 'hsl(0, 93%, 6%)',
};
const darkBg = 'hsl(0, 0%, 9%)';
const darkPaper = 'hsl(0, 0%, 14%)';
const darkElevated = 'hsl(0, 0%, 19%)';
export const getDesignTokens = (mode: PaletteMode) => {
customShadows[1] =
mode === 'dark'
? '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)'
? 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px'
: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px';
return {
@@ -121,9 +111,9 @@ export const getDesignTokens = (mode: PaletteMode) => {
contrastText: brand[50],
...(mode === 'dark' && {
contrastText: brand[50],
light: 'hsl(210, 50%, 65%)',
main: 'hsl(210, 55%, 55%)',
dark: 'hsl(210, 50%, 35%)',
light: brand[300],
main: brand[400],
dark: brand[700],
}),
},
info: {
@@ -132,10 +122,10 @@ export const getDesignTokens = (mode: PaletteMode) => {
dark: brand[600],
contrastText: gray[50],
...(mode === 'dark' && {
contrastText: 'hsl(210, 30%, 80%)',
light: 'hsl(210, 40%, 50%)',
main: 'hsl(210, 35%, 40%)',
dark: 'hsl(210, 30%, 25%)',
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
}),
},
warning: {
@@ -143,9 +133,9 @@ export const getDesignTokens = (mode: PaletteMode) => {
main: orange[400],
dark: orange[800],
...(mode === 'dark' && {
light: 'hsl(45, 60%, 55%)',
main: 'hsl(45, 55%, 45%)',
dark: 'hsl(45, 50%, 30%)',
light: orange[400],
main: orange[500],
dark: orange[700],
}),
},
error: {
@@ -153,9 +143,9 @@ export const getDesignTokens = (mode: PaletteMode) => {
main: red[400],
dark: red[800],
...(mode === 'dark' && {
light: 'hsl(0, 55%, 60%)',
main: 'hsl(0, 55%, 50%)',
dark: 'hsl(0, 50%, 35%)',
light: red[400],
main: red[500],
dark: red[700],
}),
},
success: {
@@ -163,46 +153,34 @@ export const getDesignTokens = (mode: PaletteMode) => {
main: green[400],
dark: green[800],
...(mode === 'dark' && {
light: 'hsl(120, 40%, 55%)',
main: 'hsl(120, 40%, 45%)',
dark: 'hsl(120, 35%, 30%)',
light: green[400],
main: green[500],
dark: green[700],
}),
},
grey: {
...gray,
},
divider: mode === 'dark' ? 'hsla(0, 0%, 100%, 0.08)' : alpha(gray[300], 0.4),
divider: mode === 'dark' ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4),
background: {
default: 'hsl(0, 0%, 99%)',
paper: 'hsl(220, 35%, 97%)',
...(mode === 'dark' && { default: darkBg, paper: darkPaper }),
...(mode === 'dark' && { default: gray[900], paper: 'hsl(220, 30%, 7%)' }),
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
...(mode === 'dark' && { primary: 'hsl(0, 0%, 92%)', secondary: 'hsl(0, 0%, 60%)' }),
...(mode === 'dark' && { primary: 'hsl(0, 0%, 100%)', secondary: gray[400] }),
},
action: {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
...(mode === 'dark' && {
hover: 'hsla(0, 0%, 100%, 0.06)',
selected: 'hsla(0, 0%, 100%, 0.1)',
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
}),
},
flows: {
outflows: {
primary: mode === 'dark' ? 'hsl(0, 55%, 60%)' : '#d32f2f',
surface: mode === 'dark' ? 'hsla(0, 35%, 25%, 0.6)' : '#fdecea',
text: mode === 'dark' ? 'hsl(0, 60%, 80%)' : '#b71c1c',
},
inflows: {
primary: mode === 'dark' ? 'hsl(120, 40%, 55%)' : '#2e7d32',
surface: mode === 'dark' ? 'hsla(120, 25%, 22%, 0.6)' : '#e8f5e9',
text: mode === 'dark' ? 'hsl(120, 40%, 78%)' : '#1b5e20',
},
},
},
typography: {
fontFamily: 'Inter, sans-serif',
@@ -307,18 +285,6 @@ export const colorSchemes = {
hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`,
},
flows: {
outflows: {
primary: '#d32f2f',
surface: '#fdecea',
text: '#b71c1c',
},
inflows: {
primary: '#2e7d32',
surface: '#e8f5e9',
text: '#1b5e20',
},
},
baseShadow:
'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
},
@@ -327,60 +293,49 @@ export const colorSchemes = {
palette: {
primary: {
contrastText: brand[50],
light: 'hsl(210, 50%, 65%)',
main: 'hsl(210, 55%, 55%)',
dark: 'hsl(210, 50%, 35%)',
light: brand[300],
main: brand[400],
dark: brand[700],
},
info: {
contrastText: 'hsl(210, 30%, 80%)',
light: 'hsl(210, 40%, 50%)',
main: 'hsl(210, 35%, 40%)',
dark: 'hsl(210, 30%, 25%)',
contrastText: brand[300],
light: brand[500],
main: brand[700],
dark: brand[900],
},
warning: {
light: 'hsl(45, 60%, 55%)',
main: 'hsl(45, 55%, 45%)',
dark: 'hsl(45, 50%, 30%)',
light: orange[400],
main: orange[500],
dark: orange[700],
},
error: {
light: 'hsl(0, 55%, 60%)',
main: 'hsl(0, 55%, 50%)',
dark: 'hsl(0, 50%, 35%)',
light: red[400],
main: red[500],
dark: red[700],
},
success: {
light: 'hsl(120, 40%, 55%)',
main: 'hsl(120, 40%, 45%)',
dark: 'hsl(120, 35%, 30%)',
light: green[400],
main: green[500],
dark: green[700],
},
grey: {
...gray,
},
divider: 'hsla(0, 0%, 100%, 0.08)',
divider: alpha(gray[700], 0.6),
background: {
default: darkBg,
paper: darkPaper,
default: gray[900],
paper: 'hsl(220, 30%, 7%)',
},
text: {
primary: 'hsl(0, 0%, 92%)',
secondary: 'hsl(0, 0%, 60%)',
primary: 'hsl(0, 0%, 100%)',
secondary: gray[400],
},
action: {
hover: 'hsla(0, 0%, 100%, 0.06)',
selected: 'hsla(0, 0%, 100%, 0.1)',
hover: alpha(gray[600], 0.2),
selected: alpha(gray[600], 0.3),
},
flows: {
outflows: {
primary: 'hsl(0, 55%, 60%)',
surface: 'hsla(0, 35%, 25%, 0.6)',
text: 'hsl(0, 60%, 80%)',
},
inflows: {
primary: 'hsl(120, 40%, 55%)',
surface: 'hsla(120, 25%, 22%, 0.6)',
text: 'hsl(120, 40%, 78%)',
},
},
baseShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)',
baseShadow:
'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
},
},
};