8 Commits

Author SHA1 Message Date
4cabe0be0c fix: replace nested Routes with useLocation, remove pushState+reload, add debug logs
- ProfileRoutes: removed nested <Routes> in favor of useLocation()/useNavigate()
  with strict allowlist for /profile/me and /profile/me/edit
- ProfileComponentWrapper: replaced pushState()+reload() with navigate()
- useApi: log 401 and non-401 errors
- contexts.tsx: log fetchCurrentUser lifecycle and auth:unauthorized events
- axios.ts: log auth server 401s
2026-07-19 18:12:48 +05:30
77db5e1f05 pass onUnauthorized to AuthProvider instead of separate event listener 2026-07-19 03:18:36 +05:30
d2347de932 redirect to login on auth:unauthorized event from AppContent 2026-07-19 03:12:35 +05:30
1a8f98d4a2 move BrowserRouter above AppProvider, wire onUnauthorized redirect to /login 2026-07-19 01:04:42 +05:30
804617ff27 update .run configs to point at khata-ui package.json 2026-07-18 20:20:22 +05:30
00dd507fc4 wire spec-driven auth: remove VITE_AUTH_BASE_URL, reorder providers
- main.jsx: remove VITE_AUTH_BASE_URL env var; add AppContent that
  reads authConfig from useAppContext() and passes to AuthProvider;
  reorder: AppProvider -> AuthProvider -> BrowserRouter
- sync react-openapi: AuthConfig type, extractAuthConfig, loading fix
- sync react-auth: authConfig prop, config-driven paths, server logout
2026-07-18 19:48:47 +05:30
e87a2d55ab fixes 2026-07-18 13:52:52 +05:30
5d34c51e98 wire profile components, login/register routes, spec-driven auth
- src/main.jsx: add /login, /register, /profile/* routes; wire
  profileComponents from react-auth; pass basePath to Admin
- src/Header.tsx: profile button nav to /profile/me; Login to /login
- src/openapi-config.ts: wire getToken and profileComponents
- react-auth local: sync ProfileCreate, ProfileEdit, ProfileView
- react-openapi local: sync all auth + x-profile changes
  (AppProvider, Admin, ProfileRoutes, SideMenu, types, hooks, etc.)
2026-07-17 21:00:47 +05:30
64 changed files with 2347 additions and 3931 deletions

1
.gitignore vendored
View File

@@ -14,4 +14,3 @@ dist
dist-ssr dist-ssr
*.local *.local
.idea .idea
tsconfig.tsbuildinfo

View File

@@ -3,19 +3,17 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" /> <meta name="viewport" content="initial-scale=1, width=device-width" />
<meta name="description" content="Khata — your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow." />
<meta name="theme-color" content="#635BFF" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/> />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<title>Khata — Financial Ledger</title> <title>khata - Aetoskia</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>

265
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.7", "@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7", "@mui/material": "^5.16.7",
"@mui/x-data-grid": "^7.22.0",
"@tanstack/react-query": "^5.96.1", "@tanstack/react-query": "^5.96.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"buffer": "^6.0.3", "buffer": "^6.0.3",
@@ -1096,6 +1097,201 @@
} }
} }
}, },
"node_modules/@mui/private-theming": {
"version": "7.3.11",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz",
"integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/utils": "^7.3.11",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/private-theming/node_modules/@mui/types": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
"integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/private-theming/node_modules/@mui/utils": {
"version": "7.3.11",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
"integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/types": "^7.4.12",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.3"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/styled-engine": {
"version": "7.3.10",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz",
"integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0",
"csstype": "^3.2.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/@mui/system": {
"version": "7.3.11",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz",
"integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/private-theming": "^7.3.11",
"@mui/styled-engine": "^7.3.10",
"@mui/types": "^7.4.12",
"@mui/utils": "^7.3.11",
"clsx": "^2.1.1",
"csstype": "^3.2.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/system/node_modules/@mui/types": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
"integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/system/node_modules/@mui/utils": {
"version": "7.3.11",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
"integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/types": "^7.4.12",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.3"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/types": { "node_modules/@mui/types": {
"version": "7.2.24", "version": "7.2.24",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz",
@@ -1138,6 +1334,62 @@
} }
} }
}, },
"node_modules/@mui/x-data-grid": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.29.13.tgz",
"integrity": "sha512-XHrZTvpa61eqSgUIevDzXYfYCbI7cbN/aRxSCaw2cZzQZ/USdpECYbnTEhgw5XgUlvkAK0mItKZmgKM4X7zT+Q==",
"dependencies": {
"@babel/runtime": "^7.25.7",
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0",
"@mui/x-internals": "7.29.0",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/@mui/x-internals": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz",
"integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==",
"dependencies": {
"@babel/runtime": "^7.25.7",
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@popperjs/core": { "node_modules/@popperjs/core": {
"version": "2.11.8", "version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -3922,6 +4174,11 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -4259,6 +4516,14 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vfile": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View File

@@ -13,6 +13,7 @@
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.7", "@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7", "@mui/material": "^5.16.7",
"@mui/x-data-grid": "^7.22.0",
"@tanstack/react-query": "^5.96.1", "@tanstack/react-query": "^5.96.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"buffer": "^6.0.3", "buffer": "^6.0.3",

View File

@@ -1,20 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
Box,
TextField,
Button,
Typography,
IconButton,
CircularProgress,
Link,
InputAdornment,
alpha,
} from '@mui/material';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useTheme } from '@mui/material/styles';
export type AuthMode = "login" | "register"; export type AuthMode = "login" | "register";
@@ -29,12 +15,6 @@ export interface AuthPageProps {
currentUser: any; currentUser: any;
} }
const FEATURES = [
"OpenAPI-driven admin for accounts, expenses, tags & payors",
"Import bank statements with live pipeline progress",
"Entity & tag enrichment for clean, comparable data",
];
export function AuthPage({ export function AuthPage({
mode, mode,
onBack, onBack,
@@ -45,200 +25,114 @@ export function AuthPage({
error, error,
currentUser, currentUser,
}: AuthPageProps) { }: AuthPageProps) {
const theme = useTheme();
const [username, setUsername] = React.useState(''); const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
const [showPassword, setShowPassword] = React.useState(false);
const [fieldErrors, setFieldErrors] = React.useState<{ username?: string; password?: string }>({});
const isLogin = mode === "login"; const isLogin = mode === "login";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (isLogin) {
await login(username, password);
} else {
await register(username, password);
}
};
// ✅ Auto-return if already logged in
React.useEffect(() => { React.useEffect(() => {
if (currentUser) onBack(); if (currentUser) onBack();
}, [currentUser, onBack]); }, [currentUser, onBack]);
const validate = (): boolean => { return (
const next: { username?: string; password?: string } = {}; <Box
if (!username.trim()) next.username = "Username is required"; sx={{
else if (username.trim().length < 3) next.username = "Username must be at least 3 characters"; maxWidth: 400,
if (!password) next.password = "Password is required"; mx: 'auto',
else if (password.length < 6) next.password = "Password must be at least 6 characters"; mt: 8,
setFieldErrors(next); p: 4,
return Object.keys(next).length === 0; borderRadius: 3,
}; boxShadow: 3,
bgcolor: 'background.paper',
const handleSubmit = async (e: React.FormEvent) => { }}
e.preventDefault(); >
if (!validate()) return; <IconButton onClick={onBack} sx={{ mb: 2 }}>
if (isLogin) { <ArrowBackRoundedIcon />
await login(username.trim(), password);
} else {
await register(username.trim(), password);
}
};
const handleSwitch = () => {
setFieldErrors({});
onSwitchMode();
};
const form = (
<Box sx={{ width: '100%', maxWidth: 400 }}>
<IconButton onClick={onBack} sx={{ mb: 2, border: '1px solid', borderColor: 'divider' }} aria-label="Go back">
<ArrowBackRoundedIcon fontSize="small" />
</IconButton> </IconButton>
<Typography variant="h4" fontWeight={700} gutterBottom sx={{ letterSpacing: '-0.02em' }}> <Typography variant="h4" fontWeight="bold" gutterBottom>
{isLogin ? "Sign in to Khata" : "Create your account"} {isLogin ? "Sign In" : "Create Account"}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" gutterBottom>
{isLogin {isLogin
? "Welcome back. Enter your credentials to continue." ? "Please log in to continue"
: "Start managing your ledger in a few seconds."} : "Create an account to get started"}
</Typography> </Typography>
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <TextField
<TextField fullWidth
fullWidth label="Username"
label="Username" type="username"
type="text" margin="normal"
value={username} value={username}
onChange={(e) => { onChange={(e) => setUsername(e.target.value)}
setUsername(e.target.value); required
if (fieldErrors.username) setFieldErrors((p) => ({ ...p, username: undefined })); autoFocus
}} />
error={!!fieldErrors.username} <TextField
helperText={fieldErrors.username} fullWidth
autoComplete="username" label="Password"
autoFocus type="password"
/> margin="normal"
<TextField value={password}
fullWidth onChange={(e) => setPassword(e.target.value)}
label="Password" required
type={showPassword ? 'text' : 'password'} />
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (fieldErrors.password) setFieldErrors((p) => ({ ...p, password: undefined }));
}}
error={!!fieldErrors.password}
helperText={fieldErrors.password}
autoComplete={isLogin ? 'current-password' : 'new-password'}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label={showPassword ? 'Hide password' : 'Show password'}
onClick={() => setShowPassword((s) => !s)}
edge="end"
size="small"
sx={{ border: 'none' }}
>
{showPassword ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
}}
/>
{error && ( {error && (
<Typography color="error" variant="body2" sx={{ mt: 0.5 }}> <Typography color="error" variant="body2" sx={{ mt: 1 }}>
{error} {error}
</Typography> </Typography>
)}
<Button
fullWidth
type="submit"
variant="contained"
color="primary"
sx={{ mt: 3 }}
disabled={loading}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : isLogin ? (
"Login"
) : (
"Register"
)} )}
</Button>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
sx={{ mt: 1, fontWeight: 600 }}
disabled={loading}
>
{loading ? (
<CircularProgress size={20} color="inherit" />
) : isLogin ? (
"Sign In"
) : (
"Create Account"
)}
</Button>
</Box>
</form> </form>
<Typography variant="body2" color="text.secondary" align="center" sx={{ mt: 3 }}> <Typography
{isLogin ? "Don't have an account?" : "Already have an account?"}{' '} variant="body2"
<Link component="button" underline="hover" color="primary" onClick={handleSwitch} sx={{ fontWeight: 600 }}> color="text.secondary"
{isLogin ? "Register" : "Sign In"} align="center"
sx={{ mt: 3 }}
>
{isLogin ? "Don’t have an account?" : "Already have an account?"}{' '}
<Link
component="button"
underline="hover"
color="primary"
onClick={onSwitchMode}
sx={{ fontWeight: 500 }}
>
{isLogin ? "Register" : "Login"}
</Link> </Link>
</Typography> </Typography>
</Box> </Box>
); );
return (
<Box
sx={{
minHeight: 'calc(100vh - 64px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: { xs: 2, md: 4 },
}}
>
<Box
sx={{
width: '100%',
maxWidth: 960,
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '5fr 6fr' },
borderRadius: 3,
overflow: 'hidden',
border: '1px solid',
borderColor: 'divider',
backgroundColor: 'background.paper',
boxShadow: (t) =>
`0 12px 40px ${alpha(t.palette.common.black, t.palette.mode === 'dark' ? 0.4 : 0.08)}`,
}}
>
{/* LEFT BRAND PANEL */}
<Box
sx={{
display: { xs: 'none', md: 'flex' },
flexDirection: 'column',
justifyContent: 'space-between',
p: 4,
background: 'linear-gradient(150deg, #635BFF 0%, #4F46E5 55%, #3B82F6 100%)',
color: '#fff',
}}
>
<Box>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: '-0.02em' }}>
Khata
</Typography>
<Typography sx={{ mt: 3, fontSize: '1.25rem', fontWeight: 600, lineHeight: 1.4, maxWidth: 320 }}>
The intelligent, extensible financial ledger for your cashflow.
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{FEATURES.map((f) => (
<Box key={f} sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<CheckRoundedIcon sx={{ fontSize: 18, mt: 0.25, color: alpha('#fff', 0.9) }} />
<Typography variant="body2" sx={{ color: alpha('#fff', 0.92), lineHeight: 1.5 }}>
{f}
</Typography>
</Box>
))}
</Box>
</Box>
{/* RIGHT FORM PANEL */}
<Box sx={{ p: { xs: 3, md: 5 }, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
{form}
</Box>
</Box>
</Box>
);
} }

View File

@@ -14,7 +14,7 @@ Auto-generates an admin panel (CRUD, datatable, relationship management) from an
```bash ```bash
npm install react-openapi npm install react-openapi
# peer dependencies (install these in your app): # peer dependencies (install these in your app):
npm install react react-dom react-router-dom @mui/material @mui/icons-material @emotion/react @emotion/styled axios js-yaml npm install react react-dom react-router-dom @mui/material @mui/icons-material @mui/x-data-grid @emotion/react @emotion/styled axios js-yaml
``` ```
## Quick Start ## Quick Start

View File

@@ -5,18 +5,11 @@ export { useAppContext } from "./src/context/AppContext";
export { useResource } from "./src/context/useResource"; export { useResource } from "./src/context/useResource";
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer"; export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
export { MultiEnumField } from "./src/components/fields/renderers/MultiEnumField";
export { extractFields } from "./src/transformers/field-config";
export { useFkFieldOptions } from "./src/hooks/useFkFieldOptions";
export type { FkOption } from "./src/hooks/useFkFieldOptions";
export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField";
export { FkMultiSelectField } from "./src/components/fields/renderers/FkMultiSelectField";
export { SseStreamView } from "./src/components/SseStreamView"; export { SseStreamView } from "./src/components/SseStreamView";
export { SseConnectionStatus } from "./src/components/SseConnectionStatus"; export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
export { getApi } from "./src/hooks/useApi"; export { getApi } from "./src/hooks/useApi";
export { useItemSse } from "./src/hooks/useItemSse"; export { useItemSse } from "./src/hooks/useItemSse";
export { sanitizePayload } from "./src/utils/sanitize-payload"; export { sanitizePayload } from "./src/utils/sanitize-payload";
export type { FkResolver } from "./src/utils/sanitize-payload"; export type { FkResolver } from "./src/utils/sanitize-payload";
export { formatDate, formatDateTime } from "./src/utils/datetime";
export type { FilterComponentProps } from "./src/context/useResource"; export type { FilterComponentProps } from "./src/context/useResource";
export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship, ProfileOperation, ProfileComponents, AuthConfig } from "./src/types"; export type { SpecConfiguration, ResourceConfig, FieldConfig, FKFieldConfig, ResourceRelationship, ProfileOperation, ProfileComponents, AuthConfig } from "./src/types";

View File

@@ -11,6 +11,7 @@
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"@mui/material": "^5.16.7", "@mui/material": "^5.16.7",
"@mui/icons-material": "^5.16.7", "@mui/icons-material": "^5.16.7",
"@mui/x-data-grid": "^7.22.0",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"axios": "^1.7.7", "axios": "^1.7.7",

View File

@@ -58,14 +58,9 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
if (!data) { if (!data) {
return ( return (
<Box sx={{ py: 6, textAlign: "center" }}> <Typography variant="body1" color="text.secondary" sx={{ py: 4 }}>
<Typography variant="body2" color="text.secondary"> Record not found
Record not found </Typography>
</Typography>
<Button sx={{ mt: 1.5 }} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back to list
</Button>
</Box>
); );
} }
@@ -87,7 +82,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}> <Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back Back
</Button> </Button>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1, letterSpacing: "-0.02em" }}> <Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
{applyDisplayFormat( {applyDisplayFormat(
Object.fromEntries( Object.fromEntries(
resource.orderedFields.map((field) => { resource.orderedFields.map((field) => {
@@ -145,7 +140,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
return ( return (
<TabPanel key={t.key} value={tabIndex} index={i + 1}> <TabPanel key={t.key} value={tabIndex} index={i + 1}>
{sub.streaming ? ( {sub.streaming ? (
<SseStreamView resource={sub} pathParams={{ [pathParam]: id! }} /> <SseStreamView resource={sub} pathParams={{ [pathParam]: Number(id!) }} />
) : null} ) : null}
</TabPanel> </TabPanel>
); );

View File

@@ -20,13 +20,11 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
Grid, Grid,
Skeleton,
} from "@mui/material"; } from "@mui/material";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityIcon from "@mui/icons-material/Visibility";
import StorageIcon from "@mui/icons-material/Storage";
import type { ResourceConfig, FieldConfig } from "../types"; import type { ResourceConfig, FieldConfig } from "../types";
import { useResource } from "../context/useResource"; import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext"; import { useAppContext } from "../context/AppContext";
@@ -40,10 +38,6 @@ interface ResourceListProps {
basePath: string; basePath: string;
} }
function isNumericColumn(col: FieldConfig): boolean {
return col.type === "integer" || col.type === "number";
}
function matchRow(row: any, filters: Record<string, string>, fields: FieldConfig[], allResources: ResourceConfig[]): boolean { function matchRow(row: any, filters: Record<string, string>, fields: FieldConfig[], allResources: ResourceConfig[]): boolean {
for (const field of fields) { for (const field of fields) {
if (!field.filterable) continue; if (!field.filterable) continue;
@@ -271,16 +265,12 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
/> />
)} )}
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 3 }}> <TableContainer component={Paper} variant="outlined">
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
{visibleColumns.map((col) => ( {visibleColumns.map((col) => (
<TableCell <TableCell key={col.name} sx={{ fontWeight: 700 }}>
key={col.name}
align={isNumericColumn(col) ? "right" : "left"}
sx={{ fontWeight: 700, fontSize: "0.75rem", py: 1, whiteSpace: "nowrap" }}
>
{col.sortable ? ( {col.sortable ? (
<TableSortLabel <TableSortLabel
active={sortField === col.name} active={sortField === col.name}
@@ -294,45 +284,16 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
)} )}
</TableCell> </TableCell>
))} ))}
{hasActions && <TableCell align="right" sx={{ fontWeight: 700, fontSize: "0.75rem", py: 1 }}>Actions</TableCell>} {hasActions && <TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{crud.loading && displayData.length === 0 ? ( {displayData.length === 0 ? (
Array.from({ length: 6 }).map((_, i) => (
<TableRow key={`skeleton-${i}`}>
{visibleColumns.map((col) => (
<TableCell key={col.name} align={isNumericColumn(col) ? "right" : "left"} sx={{ py: 1 }}>
<Skeleton variant="text" width={col.type === "integer" || col.type === "number" ? 56 : "80%"} />
</TableCell>
))}
{hasActions && (
<TableCell align="right" sx={{ py: 1 }}>
<Skeleton variant="rectangular" width={72} height={24} sx={{ ml: "auto", borderRadius: 1 }} />
</TableCell>
)}
</TableRow>
))
) : displayData.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={visibleColumns.length + (hasActions ? 1 : 0)} align="center"> <TableCell colSpan={visibleColumns.length + (hasActions ? 1 : 0)} align="center">
<Box sx={{ py: 5, display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}> <Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
<StorageIcon sx={{ fontSize: 32, color: "text.disabled" }} /> {isStreaming ? "Waiting for events\u2026" : "No records found"}
<Typography variant="body2" color="text.secondary"> </Typography>
{isStreaming ? "Waiting for events…" : "No records found"}
</Typography>
{!isStreaming && resource.operations.create && (
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
sx={{ mt: 1 }}
>
Create
</Button>
)}
</Box>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -342,7 +303,7 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
<TableRow <TableRow
key={rowId} key={rowId}
hover hover
sx={{ cursor: "pointer", "&:hover .row-actions": { opacity: 1 } }} sx={{ cursor: "pointer" }}
onClick={() => { onClick={() => {
if (isStreaming) { if (isStreaming) {
setDetailRow(row); setDetailRow(row);
@@ -361,56 +322,34 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
fmt = col.inlineDisplayFormat; fmt = col.inlineDisplayFormat;
} }
return ( return (
<TableCell <TableCell key={col.name}>
key={col.name}
align={isNumericColumn(col) ? "right" : "left"}
sx={{
py: 1,
fontSize: "0.8125rem",
...(isNumericColumn(col) && { fontVariantNumeric: "tabular-nums" }),
}}
>
<ListCellRenderer field={col} value={value} displayFormat={fmt} basePath={basePath} /> <ListCellRenderer field={col} value={value} displayFormat={fmt} basePath={basePath} />
</TableCell> </TableCell>
); );
})} })}
{hasActions && ( {hasActions && (
<TableCell <TableCell align="right" onClick={(e) => e.stopPropagation()}>
align="right" {resource.operations.get && !isStreaming && (
onClick={(e) => e.stopPropagation()} <Tooltip title="View">
> <IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
<Box <VisibilityIcon fontSize="small" />
className="row-actions" </IconButton>
sx={{ </Tooltip>
opacity: { xs: 1, md: 0 }, )}
display: "flex", {resource.operations.update && (
justifyContent: "flex-end", <Tooltip title="Edit">
gap: 0.5, <IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
transition: "opacity 160ms ease", <EditIcon fontSize="small" />
}} </IconButton>
> </Tooltip>
{resource.operations.get && !isStreaming && ( )}
<Tooltip title="View"> {resource.operations.delete && (
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}> <Tooltip title="Delete">
<VisibilityIcon fontSize="small" /> <IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
</IconButton> <DeleteIcon fontSize="small" />
</Tooltip> </IconButton>
)} </Tooltip>
{resource.operations.update && ( )}
<Tooltip title="Edit">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.delete && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>

View File

@@ -30,6 +30,11 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md")); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const colors = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
];
const content = ( const content = (
<Box> <Box>
<Toolbar> <Toolbar>
@@ -38,7 +43,7 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
</Typography> </Typography>
</Toolbar> </Toolbar>
<List sx={{ px: 1 }}> <List sx={{ px: 1 }}>
{resources.map((r) => { {resources.map((r, i) => {
const listPath = `${basePath}/${r.name}`; const listPath = `${basePath}/${r.name}`;
const active = location.pathname.startsWith(listPath); const active = location.pathname.startsWith(listPath);
return ( return (
@@ -53,15 +58,13 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
borderRadius: 2, borderRadius: 2,
mb: 0.5, mb: 0.5,
"&.Mui-selected": { "&.Mui-selected": {
bgcolor: "primary.main", bgcolor: `${colors[i % colors.length]}15`,
color: "primary.contrastText", "&:hover": { bgcolor: `${colors[i % colors.length]}20` },
"&:hover": { bgcolor: "primary.main" },
"& .MuiListItemIcon-root": { color: "primary.contrastText" },
}, },
}} }}
> >
<ListItemIcon sx={{ minWidth: 36 }}> <ListItemIcon sx={{ minWidth: 36 }}>
<CircleIcon sx={{ fontSize: 12 }} /> <CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={r.displayName} primary={r.displayName}

View File

@@ -1,10 +1,7 @@
import React from "react"; import React from "react";
import { Box, Typography, Avatar } from "@mui/material"; import { Box, Typography } from "@mui/material";
import type { FieldConfig } from "../../types"; import type { FieldConfig } from "../../types";
import { ListCellRenderer } from "./ListCellRenderer"; import { ListCellRenderer } from "./ListCellRenderer";
import { CurrencyField } from "./renderers/CurrencyField";
import { resolveMediaUrl } from "./utils";
import { useAppContext } from "../../context/AppContext";
interface DetailFieldProps { interface DetailFieldProps {
field: FieldConfig; field: FieldConfig;
@@ -14,7 +11,6 @@ interface DetailFieldProps {
} }
export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) { export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) {
const { config } = useAppContext();
if (field.hidden?.detail) return null; if (field.hidden?.detail) return null;
return ( return (
@@ -22,13 +18,7 @@ export function DetailFieldRenderer({ field, value, displayFormat, basePath }: D
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}> <Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{field.label} {field.label}
</Typography> </Typography>
{field.uiType === "currency" ? ( <ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
<CurrencyField value={Number(value)} large />
) : field.uiType === "image" ? (
<Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
)}
</Box> </Box>
); );
} }

View File

@@ -6,7 +6,6 @@ import { NumberField } from "./renderers/NumberField";
import { DateField } from "./renderers/DateField"; import { DateField } from "./renderers/DateField";
import { BooleanField } from "./renderers/BooleanField"; import { BooleanField } from "./renderers/BooleanField";
import { EnumField } from "./renderers/EnumField"; import { EnumField } from "./renderers/EnumField";
import { MultiEnumField } from "./renderers/MultiEnumField";
import { FkSelectField } from "./renderers/FkSelectField"; import { FkSelectField } from "./renderers/FkSelectField";
import { FkMultiSelectField } from "./renderers/FkMultiSelectField"; import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
import { FileUploadField } from "./renderers/FileUploadField"; import { FileUploadField } from "./renderers/FileUploadField";
@@ -82,17 +81,6 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
); );
} }
if (field.isArray && field.enumValues) {
return (
<MultiEnumField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.enumValues) { if (field.enumValues) {
return ( return (
<EnumField <EnumField
@@ -108,17 +96,6 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
return <BooleanField field={field} value={value} onChange={onChange} />; return <BooleanField field={field} value={value} onChange={onChange} />;
} }
if (field.uiType === "currency") {
return (
<NumberField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.type === "integer" || field.type === "number") { if (field.type === "integer" || field.type === "number") {
return ( return (
<NumberField <NumberField

View File

@@ -2,10 +2,8 @@ import React, { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material"; import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../types"; import type { FieldConfig } from "../../types";
import { applyDisplayFormat, resolveMediaUrl } from "./utils"; import { applyDisplayFormat } from "./utils";
import { formatByFieldFormat } from "../../utils/datetime";
import { InlineRefField } from "./renderers/InlineRefField"; import { InlineRefField } from "./renderers/InlineRefField";
import { CurrencyField } from "./renderers/CurrencyField";
import { extractFields } from "../../transformers/field-config"; import { extractFields } from "../../transformers/field-config";
import { useAppContext } from "../../context/AppContext"; import { useAppContext } from "../../context/AppContext";
@@ -18,7 +16,7 @@ interface ListCellProps {
export function ListCellRenderer({ field, value, displayFormat, basePath }: ListCellProps) { export function ListCellRenderer({ field, value, displayFormat, basePath }: ListCellProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { schemas, config } = useAppContext(); const { schemas } = useAppContext();
const [inlineItem, setInlineItem] = useState<any>(null); const [inlineItem, setInlineItem] = useState<any>(null);
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -58,7 +56,7 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List
{sf.label} {sf.label}
</Typography> </Typography>
<Typography variant="body2"> <Typography variant="body2">
{fv == null ? "—" : typeof fv === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(fv, sf.inlineDisplayFormat) : JSON.stringify(fv)) : formatByFieldFormat(fv, sf.format)} {fv == null ? "—" : typeof fv === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(fv, sf.inlineDisplayFormat) : JSON.stringify(fv)) : String(fv)}
</Typography> </Typography>
</Box> </Box>
</Grid> </Grid>
@@ -139,11 +137,7 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List
} }
if (field.uiType === "image" && value) { if (field.uiType === "image" && value) {
return <Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 40, height: 40 }} />; return <Avatar src={value} variant="rounded" sx={{ width: 40, height: 40 }} />;
}
if (field.uiType === "currency" && value != null && !Number.isNaN(Number(value))) {
return <CurrencyField value={Number(value)} />;
} }
if (field.type === "boolean") { if (field.type === "boolean") {
@@ -154,5 +148,5 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List
return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>; return <Typography variant="body2">{applyDisplayFormat(value, displayFormat ?? "")}</Typography>;
} }
return <Typography variant="body2">{formatByFieldFormat(value, field.format)}</Typography>; return <Typography variant="body2">{String(value)}</Typography>;
} }

View File

@@ -3,5 +3,3 @@ export { ListCellRenderer } from "./ListCellRenderer";
export { DetailFieldRenderer } from "./DetailFieldRenderer"; export { DetailFieldRenderer } from "./DetailFieldRenderer";
export { applyDisplayFormat } from "./utils"; export { applyDisplayFormat } from "./utils";
export { JsonField } from "./renderers/JsonField"; export { JsonField } from "./renderers/JsonField";
export { MultiEnumField } from "./renderers/MultiEnumField";
export { CurrencyField, formatCurrency } from "./renderers/CurrencyField";

View File

@@ -1,42 +0,0 @@
import React from "react";
import { Typography } from "@mui/material";
const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"];
const _currencyFormatters = new Map<string, Intl.NumberFormat>();
export function formatCurrency(amount: number, currency?: string): string {
const code = currency && CURRENCIES.includes(currency) ? currency : "INR";
let formatter = _currencyFormatters.get(code);
if (!formatter) {
formatter = new Intl.NumberFormat("en-IN", {
style: "currency",
currency: code,
maximumFractionDigits: 2,
});
_currencyFormatters.set(code, formatter);
}
return formatter.format(Math.abs(amount));
}
interface CurrencyFieldProps {
value: number;
currency?: string;
large?: boolean;
}
export function CurrencyField({ value, currency, large }: CurrencyFieldProps) {
const negative = value < 0;
return (
<Typography
component="span"
sx={{
fontWeight: 700,
fontSize: large ? "0.9375rem" : "0.8125rem",
fontVariantNumeric: "tabular-nums",
color: negative ? "error.main" : "success.main",
}}
>
{formatCurrency(value, currency)}
</Typography>
);
}

View File

@@ -10,26 +10,11 @@ interface Props {
} }
export function DateField({ field, value, onChange, error }: Props) { export function DateField({ field, value, onChange, error }: Props) {
// `x-edit-as: date` renders a date-time field as a plain date picker; const inputType = field.format === "date" ? "date" : "datetime-local";
// the picked day is submitted as midnight so the stored type is intact.
const editAsDate = field.format === "date" || field.editAs === "date";
const inputType = editAsDate ? "date" : "datetime-local"; const normalized = field.format === "date-time" && typeof value === "string"
? value.replace(/\.\d+Z$/, "").replace(/Z$/, "")
const normalized = (() => { : value;
if (field.format !== "date-time" || typeof value !== "string") return value;
if (editAsDate) return value.slice(0, 10);
return value.replace(/\.\d+Z$/, "").replace(/Z$/, "");
})();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
if (editAsDate && field.format === "date-time" && /^\d{4}-\d{2}-\d{2}$/.test(v)) {
onChange(`${v}T00:00:00`);
} else {
onChange(v);
}
};
return ( return (
<TextField <TextField
@@ -37,7 +22,7 @@ export function DateField({ field, value, onChange, error }: Props) {
label={field.label} label={field.label}
type={inputType} type={inputType}
value={normalized ?? ""} value={normalized ?? ""}
onChange={handleChange} onChange={(e) => onChange(e.target.value)}
error={!!error} error={!!error}
helperText={error || field.description || undefined} helperText={error || field.description || undefined}
placeholder={field.description || undefined} placeholder={field.description || undefined}

View File

@@ -2,8 +2,6 @@ import React from "react";
import { Box, Typography, Avatar, Chip, Button, FormHelperText } from "@mui/material"; import { Box, Typography, Avatar, Chip, Button, FormHelperText } from "@mui/material";
import type { FieldConfig } from "../../../types"; import type { FieldConfig } from "../../../types";
import { getApi } from "../../../hooks/useApi"; import { getApi } from "../../../hooks/useApi";
import { useAppContext } from "../../../context/AppContext";
import { resolveMediaUrl } from "../utils";
interface Props { interface Props {
field: FieldConfig; field: FieldConfig;
@@ -21,7 +19,6 @@ const acceptMap: Record<string, string> = {
export function FileUploadField({ field, value, onChange }: Props) { export function FileUploadField({ field, value, onChange }: Props) {
const uploadConfig = field.upload!; const uploadConfig = field.upload!;
const inputRef = React.useRef<HTMLInputElement>(null); const inputRef = React.useRef<HTMLInputElement>(null);
const { config } = useAppContext();
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -54,12 +51,12 @@ export function FileUploadField({ field, value, onChange }: Props) {
</Typography> </Typography>
{value ? ( {value ? (
uploadConfig.type === "image" ? ( uploadConfig.type === "image" ? (
<Avatar src={resolveMediaUrl(`/uploads/${value}`, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} /> <Avatar src={`/uploads/${value}`} variant="rounded" sx={{ width: 120, height: 120 }} />
) : ( ) : (
<Chip <Chip
label={value} label={value}
component="a" component="a"
href={resolveMediaUrl(`/uploads/${value}`, config.baseApiUrl)} href={`/uploads/${value}`}
clickable clickable
onDelete={handleReplace} onDelete={handleReplace}
/> />

View File

@@ -3,8 +3,6 @@ import { Box, Typography, Avatar, FormHelperText } from "@mui/material";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import type { FieldConfig } from "../../../types"; import type { FieldConfig } from "../../../types";
import { getApi } from "../../../hooks/useApi"; import { getApi } from "../../../hooks/useApi";
import { useAppContext } from "../../../context/AppContext";
import { resolveMediaUrl } from "../utils";
interface Props { interface Props {
field: FieldConfig; field: FieldConfig;
@@ -15,7 +13,6 @@ interface Props {
} }
export function ImageField({ field, value, onChange, id, uploadUrl }: Props) { export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
const { config } = useAppContext();
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -50,7 +47,7 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
{field.label} {field.label}
</Typography> </Typography>
{value ? ( {value ? (
<Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} /> <Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
) : ( ) : (
<Button variant="outlined" component="label" size="small"> <Button variant="outlined" component="label" size="small">
Upload {field.label} Upload {field.label}

View File

@@ -2,7 +2,6 @@ import React, { useState } from "react";
import { Box, Typography, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material"; import { Box, Typography, Chip, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../../types"; import type { FieldConfig } from "../../../types";
import { applyDisplayFormat } from "../utils"; import { applyDisplayFormat } from "../utils";
import { formatByFieldFormat } from "../../../utils/datetime";
import { extractFields } from "../../../transformers/field-config"; import { extractFields } from "../../../transformers/field-config";
import { useAppContext } from "../../../context/AppContext"; import { useAppContext } from "../../../context/AppContext";
@@ -69,7 +68,7 @@ export function InlineRefField({ field, value }: Props) {
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}> <Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{sf.label} {sf.label}
</Typography> </Typography>
<Typography variant="body2">{value?.[sf.name] == null ? "—" : typeof value[sf.name] === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(value[sf.name], sf.inlineDisplayFormat) : JSON.stringify(value[sf.name])) : formatByFieldFormat(value[sf.name], sf.format)}</Typography> <Typography variant="body2">{value?.[sf.name] == null ? "—" : typeof value[sf.name] === "object" ? (sf.inlineDisplayFormat ? applyDisplayFormat(value[sf.name], sf.inlineDisplayFormat) : JSON.stringify(value[sf.name])) : String(value[sf.name])}</Typography>
</Box> </Box>
</Grid> </Grid>
))} ))}

View File

@@ -1,59 +0,0 @@
import React from "react";
import { Autocomplete, TextField, Chip, Box } from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import type { FieldConfig } from "../../../types";
interface Props {
field: FieldConfig;
value: any;
onChange: (value: any[]) => void;
error?: string;
}
export function MultiEnumField({ field, value, onChange, error }: Props) {
const selected: string[] = Array.isArray(value) ? value : [];
return (
<Autocomplete
multiple
disableCloseOnSelect
size="small"
options={field.enumValues ?? []}
value={selected}
onChange={(_, newVal) => onChange(newVal)}
renderOption={(props, option, { selected: isSelected }) => {
const { key, ...rest } = props as any;
return (
<li key={key} {...rest}>
{isSelected ? (
<DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} />
) : (
<Box sx={{ width: 22, mr: 1 }} />
)}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) =>
tagValue.map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag} size="small" />;
})
}
renderInput={(params) => (
<TextField
{...params}
label={field.label}
placeholder={field.description || undefined}
error={!!error}
helperText={error || field.description || undefined}
size="small"
/>
)}
sx={{
"& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
}}
disabled={field.readOnly}
/>
);
}

View File

@@ -1,5 +1,3 @@
import { formatIsoLike } from "../../utils/datetime";
function getNested(obj: any, path: string): any { function getNested(obj: any, path: string): any {
return path.split(".").reduce((o, k) => o?.[k], obj); return path.split(".").reduce((o, k) => o?.[k], obj);
} }
@@ -8,18 +6,6 @@ export function applyDisplayFormat(item: any, format: string): string {
if (!item || typeof item !== "object") return String(item ?? ""); if (!item || typeof item !== "object") return String(item ?? "");
return format.replace(/\{([\w.]+)\}/g, (_, key) => { return format.replace(/\{([\w.]+)\}/g, (_, key) => {
const val = getNested(item, key); const val = getNested(item, key);
return val != null ? formatIsoLike(val) : ""; return val != null ? String(val) : "";
}); });
} }
/**
* Resolve a media/image URL against the API base so relative paths
* like `/uploads/entity_logos/x.svg` point at the backend origin.
* Absolute (http/data/blob) URLs are returned untouched.
*/
export function resolveMediaUrl(value: string | null | undefined, baseUrl?: string): string | undefined {
if (value == null || value === "") return undefined;
if (value.startsWith("http") || value.startsWith("data:") || value.startsWith("blob:")) return value;
if (value.startsWith("/") && baseUrl) return `${baseUrl.replace(/\/+$/, "")}${value}`;
return value;
}

View File

@@ -43,20 +43,6 @@ function extractProfileOperations(spec: any): ProfileOperation[] {
return ops; return ops;
} }
function applyResourceOverrides(configs: ResourceConfig[], specConfiguration: SpecConfiguration): ResourceConfig[] {
for (const resource of configs) {
const fieldTypes = specConfiguration.resourceConfig?.[resource.name]?.fieldTypes;
if (!fieldTypes) continue;
// fields and orderedFields share the same FieldConfig object references,
// so an in-place mutation updates both.
for (const field of resource.fields) {
const override = fieldTypes[field.name];
if (override) field.uiType = override;
}
}
return configs;
}
const DEFAULT_AUTH_CONFIG: AuthConfig = { const DEFAULT_AUTH_CONFIG: AuthConfig = {
serverUrl: "", serverUrl: "",
loginPath: "/login", loginPath: "/login",
@@ -102,7 +88,7 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
if (errs.length === 0) { if (errs.length === 0) {
const configs = buildResourceConfigs(spec); const configs = buildResourceConfigs(spec);
if (!cancelled) { if (!cancelled) {
setResources(applyResourceOverrides(configs, specConfiguration)); setResources(configs);
} }
const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? ""; const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? "";

View File

@@ -56,7 +56,6 @@ interface UseResourceReturn {
get: (id: string | number, params?: Record<string, any>) => Promise<any>; get: (id: string | number, params?: Record<string, any>) => Promise<any>;
create: (data: any) => Promise<any>; create: (data: any) => Promise<any>;
update: (id: string | number, data: any) => Promise<any>; update: (id: string | number, data: any) => Promise<any>;
patch?: (id: string | number, data: any) => Promise<any>;
remove: (id: string | number) => Promise<void>; remove: (id: string | number) => Promise<void>;
stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription; stream?: (handlers: StreamHandlers, pathParams?: Record<string, string | number>) => StreamSubscription;
loading: boolean; loading: boolean;
@@ -597,7 +596,7 @@ export function useResource(resourceName: string): UseResourceReturn {
const rPath = resource?.path; const rPath = resource?.path;
const rPagination = resource?.pagination; const rPagination = resource?.pagination;
const rPatch = resource ? (resource.operations.patch || undefined) : false; const rUpdateMethod = resource?.updateMethod;
const rStreaming = resource?.streaming; const rStreaming = resource?.streaming;
const rFields = resource?.fields; const rFields = resource?.fields;
@@ -707,7 +706,8 @@ export function useResource(resourceName: string): UseResourceReturn {
const sanitized = rFields && schemas const sanitized = rFields && schemas
? await sanitizePayload(data, rFields, schemas, resolveFk) ? await sanitizePayload(data, rFields, schemas, resolveFk)
: data; : data;
const res = await api.put(`${rPath}/${id}`, sanitized); const method = rUpdateMethod ?? "put";
const res = await (method === "patch" ? api.patch : api.put)(`${rPath}/${id}`, sanitized);
return res.data; return res.data;
} catch (e: any) { } catch (e: any) {
setError(parseError(e)); setError(parseError(e));
@@ -716,29 +716,7 @@ export function useResource(resourceName: string): UseResourceReturn {
setLoading(false); setLoading(false);
} }
}, },
[rPath, rFields, schemas, resolveFk, setLoading, setError] [rPath, rFields, schemas, resolveFk, rUpdateMethod, setLoading, setError]
);
const _patch = useCallback(
async (id: string | number, data: any): Promise<any> => {
if (!rPath) throw new Error(`Resource "${resourceName}" not found yet`);
setLoading(true);
setError(null);
try {
const api = getApi();
const sanitized = rFields && schemas
? await sanitizePayload(data, rFields, schemas, resolveFk)
: data;
const res = await api.patch(`${rPath}/${id}`, sanitized);
return res.data;
} catch (e: any) {
setError(parseError(e));
throw e;
} finally {
setLoading(false);
}
},
[rPath, rFields, schemas, resolveFk, setLoading, setError]
); );
const remove = useCallback( const remove = useCallback(
@@ -813,7 +791,6 @@ export function useResource(resourceName: string): UseResourceReturn {
get, get,
create, create,
update, update,
patch: undefined,
remove, remove,
stream: undefined, stream: undefined,
loading: false, loading: false,
@@ -821,5 +798,5 @@ export function useResource(resourceName: string): UseResourceReturn {
}; };
} }
return { resource, components, list, get, create, update, patch: rPatch ? _patch : undefined, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error }; return { resource, components, list, get, create, update, remove, stream: rStreaming ? stream : undefined, loading: state.loading, error: state.error };
} }

View File

@@ -3,24 +3,6 @@ import axios, { AxiosInstance } from "axios";
let apiClient: AxiosInstance | null = null; let apiClient: AxiosInstance | null = null;
let _onUnauthorized: (() => void) | undefined; let _onUnauthorized: (() => void) | undefined;
function serializeParams(params: Record<string, any>): string {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params ?? {})) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const item of value) searchParams.append(key, String(item));
} else if (typeof value === "object") {
for (const [nestedKey, nestedValue] of Object.entries(value)) {
if (nestedValue === undefined || nestedValue === null) continue;
searchParams.append(`${key}[${nestedKey}]`, String(nestedValue));
}
} else {
searchParams.append(key, String(value));
}
}
return searchParams.toString();
}
export function initApi(baseUrl: string, getToken?: () => string | null, onUnauthorized?: () => void): AxiosInstance { export function initApi(baseUrl: string, getToken?: () => string | null, onUnauthorized?: () => void): AxiosInstance {
if (apiClient && apiClient.defaults.baseURL === baseUrl) { if (apiClient && apiClient.defaults.baseURL === baseUrl) {
_onUnauthorized = onUnauthorized; _onUnauthorized = onUnauthorized;
@@ -32,7 +14,6 @@ export function initApi(baseUrl: string, getToken?: () => string | null, onUnaut
apiClient = axios.create({ apiClient = axios.create({
baseURL: baseUrl, baseURL: baseUrl,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
paramsSerializer: serializeParams,
}); });
apiClient.interceptors.request.use((config) => { apiClient.interceptors.request.use((config) => {

View File

@@ -1,82 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import type { FieldConfig, FKFieldConfig } from "../types";
import { useAppContext } from "../context/AppContext";
import { getApi } from "./useApi";
export interface FkOption {
value: any;
label: string;
}
function applyFormat(obj: any, format: string): string {
if (!obj || typeof obj !== "object") return String(obj ?? "");
return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
}
/**
* Loads select options for FK-annotated fields of a field set.
* Option values bind to `fk.value` when set (e.g. account names),
* otherwise to the target resource's primary key.
*/
export function useFkFieldOptions(fields: FieldConfig[]) {
const { resources } = useAppContext();
const [fkOptions, setFkOptions] = useState<Record<string, FkOption[]>>({});
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
const loadFkOptions = useCallback(
async (fieldName: string, fk: FKFieldConfig) => {
setFkLoading((prev) => ({ ...prev, [fieldName]: true }));
try {
const targetRes = resources.find((r) => r.name === fk.resource);
if (!targetRes) return;
const api = getApi();
const params: Record<string, any> = {};
if (targetRes.pagination) params.limit = 0;
const res = await api.get(targetRes.path, { params });
let items: any[];
if (targetRes.pagination) {
if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) {
throw new Error(`Expected paginated response from ${targetRes.path}`);
}
items = res.data.items;
} else {
if (!Array.isArray(res.data)) {
throw new Error(`Expected array response from ${targetRes.path}`);
}
items = res.data;
}
const opts: FkOption[] = items.map((item: any) => ({
value: item[fk.value ?? targetRes.primaryKey],
label: applyFormat(item, targetRes.displayFormat),
}));
setFkOptions((prev) => ({ ...prev, [fieldName]: opts }));
} catch {
// leave options empty; the field renders without suggestions
} finally {
setFkLoading((prev) => ({ ...prev, [fieldName]: false }));
}
},
[resources]
);
useEffect(() => {
fields.forEach((f) => {
if (f.fk?.prefetch) loadFkOptions(f.name, f.fk);
});
}, [fields, loadFkOptions]);
const loadFkOnOpen = useCallback(
(fieldName: string) => {
const field = fields.find((f) => f.name === fieldName);
if (field?.fk && !field.fk.prefetch && !fkOptions[fieldName]) {
loadFkOptions(fieldName, field.fk);
}
},
[fields, fkOptions, loadFkOptions]
);
return { fkOptions, fkLoading, loadFkOnOpen };
}

View File

@@ -64,7 +64,6 @@ function extractOneOfOptions(schema: any, schemas: Record<string, any>, discrimi
description: prop["x-description"] ?? "", description: prop["x-description"] ?? "",
type: prop.type ?? "string", type: prop.type ?? "string",
format: prop.format, format: prop.format,
editAs: prop["x-edit-as"],
order: prop["x-order"] ?? Infinity, order: prop["x-order"] ?? Infinity,
hidden: prop["x-hidden"] ?? {}, hidden: prop["x-hidden"] ?? {},
filterable: prop["x-filterable"] ?? false, filterable: prop["x-filterable"] ?? false,
@@ -131,17 +130,13 @@ export function extractFields(schemaName: string, schema: any, schemas: Record<s
description: prop["x-description"] ?? "", description: prop["x-description"] ?? "",
type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"), type: isRef && refSchema ? "object" : isOneOf ? "object" : (prop.type ?? "string"),
format: prop.format, format: prop.format,
editAs: prop["x-edit-as"],
order: prop["x-order"] ?? Infinity, order: prop["x-order"] ?? Infinity,
hidden: prop["x-hidden"] ?? {}, hidden: prop["x-hidden"] ?? {},
filterable: prop["x-filterable"] ?? false, filterable: prop["x-filterable"] ?? false,
sortable: prop["x-sortable"] ?? false, sortable: prop["x-sortable"] ?? false,
readOnly: prop.readOnly ?? false, readOnly: prop.readOnly ?? false,
required: requiredFields.includes(name), required: requiredFields.includes(name),
// Lift enum constraints from array items so arrays-of-enum can render enumValues: prop.enum,
// as multi-selects (e.g. ReportQuery.granularities).
enumValues: prop.enum ?? (prop.type === "array" ? prop.items?.enum : undefined),
defaultValue: prop.default,
fk: prop["x-fk"], fk: prop["x-fk"],
uiType: prop["x-ui-type"], uiType: prop["x-ui-type"],
uploadUrl: prop["x-upload-url"], uploadUrl: prop["x-upload-url"],

View File

@@ -98,9 +98,9 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
const parent = nameMap.get(parentName); const parent = nameMap.get(parentName);
if (!parent) continue; if (!parent) continue;
if (hasOperation(pathObj, "get")) parent.operations.get = true; if (hasOperation(pathObj, "get")) parent.operations.get = true;
if (hasOperation(pathObj, "put")) parent.operations.update = true; if (hasOperation(pathObj, "put") || hasOperation(pathObj, "patch")) parent.operations.update = true;
if (hasOperation(pathObj, "patch")) parent.operations.patch = true;
if (hasOperation(pathObj, "delete")) parent.operations.delete = true; if (hasOperation(pathObj, "delete")) parent.operations.delete = true;
if (hasOperation(pathObj, "patch") && !hasOperation(pathObj, "put")) parent.updateMethod = "patch";
continue; continue;
} }
@@ -127,8 +127,8 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields, fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: [], orderedFields: [],
operations: hasSSE operations: hasSSE
? { list: true, get: false, create: false, update: false, patch: false, delete: false } ? { list: true, get: false, create: false, update: false, delete: false }
: { list: hasOperation(pathObj, "get"), get: false, create: false, update: false, patch: false, delete: false }, : { list: hasOperation(pathObj, "get"), get: false, create: false, update: false, delete: false },
updateMethod: "put", updateMethod: "put",
pagination: hasSSE ? null : detectPagination(pathObj), pagination: hasSSE ? null : detectPagination(pathObj),
relationships: [], relationships: [],
@@ -172,9 +172,9 @@ export function buildResourceConfigs(spec: OpenApiSpec): ResourceConfig[] {
listColumns: schema?.["x-list-columns"] ?? [], listColumns: schema?.["x-list-columns"] ?? [],
fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields, fields: hasSSE ? [SSE_RECEIVED_FIELD, ...fields.map((f) => ({ ...f, readOnly: true }))] : fields,
orderedFields: [], orderedFields: [],
operations: hasSSE operations: hasSSE
? { list: true, get: false, create: false, update: false, patch: false, delete: false } ? { list: true, get: false, create: false, update: false, delete: false }
: { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, patch: false, delete: false }, : { list: hasOperation(pathObj, "get"), get: false, create: hasOperation(pathObj, "post"), update: false, delete: false },
updateMethod: "put", updateMethod: "put",
pagination: hasSSE ? null : detectPagination(pathObj), pagination: hasSSE ? null : detectPagination(pathObj),
relationships, relationships,

View File

@@ -4,8 +4,6 @@ export interface ResourceConfiguration {
filterOptions?: { filterOptions?: {
mode?: FilterMode; mode?: FilterMode;
}; };
/** Map of field name → uiType override (e.g. { amount: "currency" }). */
fieldTypes?: Record<string, string>;
} }
export interface ProfileComponents { export interface ProfileComponents {
@@ -67,7 +65,6 @@ export interface ResourceConfig {
get: boolean; get: boolean;
create: boolean; create: boolean;
update: boolean; update: boolean;
patch: boolean;
delete: boolean; delete: boolean;
}; };
updateMethod: "put" | "patch"; updateMethod: "put" | "patch";
@@ -85,8 +82,6 @@ export interface ResourceConfig {
export interface FKFieldConfig { export interface FKFieldConfig {
resource: string; resource: string;
prefetch: boolean; prefetch: boolean;
/** Target property used as the wire value (defaults to the target's primary key). */
value?: string;
} }
export interface OneOfOption { export interface OneOfOption {
@@ -101,8 +96,6 @@ export interface FieldConfig {
description: string; description: string;
type: string; type: string;
format?: string; format?: string;
/** `x-edit-as: date` — edit a `date-time` field with a plain date input; submits midnight. */
editAs?: string;
order: number; order: number;
hidden: { form?: boolean; list?: boolean; detail?: boolean }; hidden: { form?: boolean; list?: boolean; detail?: boolean };
filterable: boolean; filterable: boolean;
@@ -110,8 +103,6 @@ export interface FieldConfig {
readOnly: boolean; readOnly: boolean;
required: boolean; required: boolean;
enumValues?: string[]; enumValues?: string[];
/** Schema-level `default` used to seed create-form state. */
defaultValue?: any;
fk?: FKFieldConfig; fk?: FKFieldConfig;
uiType?: string; uiType?: string;
uploadUrl?: string; uploadUrl?: string;

View File

@@ -1,63 +0,0 @@
/**
* Zero-dependency date/time display helpers.
*
* Only ISO-shaped strings ("2026-08-24", "2026-08-24T09:07:25[.f][Z]") are
* reformatted; anything else (DD-MM-YYYY expense strings, free text) passes
* through untouched. Naive strings without a zone are parsed as clock time
* so the displayed wall time matches what the backend stored.
*/
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
function parseIso(value: any): Date | null {
if (typeof value !== "string") return null;
if (!ISO_DATE.test(value) && !ISO_DATETIME.test(value)) return null;
const d = new Date(value);
return isNaN(d.getTime()) ? null : d;
}
const DATE_FMT = new Intl.DateTimeFormat("en-IN", { day: "numeric", month: "short", year: "numeric" });
const DATETIME_FMT = new Intl.DateTimeFormat("en-IN", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
function fallback(value: any): string {
return typeof value === "string" ? value : value != null ? String(value) : "";
}
/** "2026-08-24" -> "24 Aug 2026"; non-ISO values pass through. */
export function formatDate(value: any): string {
const d = parseIso(value);
return d ? DATE_FMT.format(d) : fallback(value);
}
/** "2026-08-24T09:07:25" -> "24 Aug 2026, 09:07"; non-ISO values pass through. */
export function formatDateTime(value: any): string {
const d = parseIso(value);
return d ? DATETIME_FMT.format(d) : fallback(value);
}
/** Route by OpenAPI field format ("date" | "date-time"); other formats render plainly. */
export function formatByFieldFormat(value: any, format?: string): string {
if (format === "date") return formatDate(value);
if (format === "date-time") return formatDateTime(value);
return fallback(value);
}
/**
* Metadata-free variant for display-format templates ({key} interpolation):
* reformats a scalar only when it happens to be ISO-shaped.
*/
export function formatIsoLike(value: any): string {
if (typeof value === "string") {
if (ISO_DATETIME.test(value)) return formatDateTime(value);
if (ISO_DATE.test(value)) return formatDate(value);
}
return fallback(value);
}

View File

@@ -29,9 +29,7 @@ export async function sanitizePayload(
const val = result[key]; const val = result[key];
// FK fields whose wire value is a plain target property (fk.value, e.g. if (field.fk) {
// account names) are already in wire format — no id→object resolution.
if (field.fk && !field.fk.value) {
if (val == null || val === "") { if (val == null || val === "") {
result[key] = null; result[key] = null;
} else if (resolveFk) { } else if (resolveFk) {

View File

@@ -1,126 +0,0 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Container,
Box,
Paper,
Typography,
Alert,
Skeleton,
Button,
} from "@mui/material";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import { useNavigate } from "react-router-dom";
import { useResource, useAppContext, formatCurrency } from "../react-openapi";
import { PageHeader } from "./ui/PageHeader";
import { EmptyState } from "./ui/EmptyState";
import { StatCard } from "./common/components/StatCard";
import { TransactionList } from "./common/components/TransactionList";
import { buildTxnFieldConfigs } from "./common/utils/fieldConfigs";
import { isExpense } from "./common/utils/transactions";
import { currentMonthKey, monthKey, monthLabel, parseOccurredAt } from "./common/utils/dates";
import type { ExpenseItem } from "./common/types";
export default function Expense() {
const navigate = useNavigate();
const { list, loading, error, resource } = useResource("expenses");
const { resources } = useAppContext();
const [items, setItems] = useState<ExpenseItem[] | null>(null);
const fieldConfigs = useMemo(() => buildTxnFieldConfigs(resources), [resources]);
useEffect(() => {
let mounted = true;
list({ limit: 0 }).then((res) => {
if (!mounted) return;
setItems((res.items ?? []) as ExpenseItem[]);
});
return () => {
mounted = false;
};
}, [list]);
const sorted = useMemo(
() =>
[...(items ?? [])].sort(
(a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
),
[items],
);
const summary = useMemo(() => {
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
const totalSpent = sorted.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0);
const totalIncome = sorted.filter((it) => !isExpense(it)).reduce((s, it) => s + (it.amount ?? 0), 0);
const thisMonth = currentMonthKey();
const monthItems = sorted.filter((it) => monthKey(it.occurred_at) === thisMonth);
const monthTotal = monthItems.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0);
return { currency, totalSpent, totalIncome, thisMonth: monthLabel(thisMonth), monthItems, monthTotal };
}, [sorted]);
const loadingUI = (
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
{[0, 1, 2].map((i) => (
<Box key={i}>
<Skeleton variant="text" width={160} height={32} sx={{ mb: 1.5 }} />
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{[0, 1, 2].map((j) => (
<Skeleton key={j} variant="rounded" height={64} sx={{ borderRadius: 2 }} />
))}
</Box>
</Box>
))}
</Box>
);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Expenses" }]}
title="Expenses"
subtitle="Every transaction imported from your accounts, grouped by month."
actions={
<Button
variant="contained"
onClick={() => navigate("/admin/expenses/new")}
>
New Expense
</Button>
}
/>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
Failed to load expenses: {error}
</Alert>
)}
{items === null && !error ? (
loadingUI
) : items && items.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 3, mt: 2 }}>
<EmptyState
icon={<ReceiptLongIcon />}
title="No expenses yet"
description="Once you run a fetch request, imported transactions will show up here grouped by month."
actionLabel="Go to Fetch Requests"
onAction={() => navigate("/fetch-requests")}
/>
</Paper>
) : (
<>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 4 }}>
<StatCard
label="Outflows"
value={formatCurrency(summary.totalSpent, summary.currency)}
hint={`${summary.monthItems.length} transaction${summary.monthItems.length === 1 ? "" : "s"} this month`}
/>
<StatCard label="This month" value={formatCurrency(summary.monthTotal, summary.currency)} hint={summary.thisMonth} />
<StatCard label="Inflows" value={formatCurrency(summary.totalIncome, summary.currency)} />
</Box>
{fieldConfigs && <TransactionList items={sorted} fields={fieldConfigs} />}
</>
)}
</Container>
);
}

View File

@@ -1,60 +1,19 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
Box, Container, Typography, Paper, Button, Alert, Box, Container, Typography, Paper, Button, Alert,
CircularProgress, IconButton, Tooltip, CircularProgress,
} from "@mui/material"; } from "@mui/material";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import { alpha } from "@mui/material/styles";
import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi"; import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi"; import type { FieldConfig } from "../../react-openapi";
import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState";
import { formatApiError } from "../features/fetch-requests";
import { useToast } from "../ui/Toast";
const CREATE_FIELDS = ["account", "bank", "pipeline", "trust_fallback", "start_date", "end_date", "source"]; const CREATE_FIELDS = ["account", "format", "start_date", "end_date", "source"];
// Two-pane form partition — explicit names, spec order is unreliable.
// Grid stretch keeps both panes equal-height regardless of which source
// variant is active (email adds From/Subject under the type select).
const SOURCE_PANE_FIELDS = ["account", "bank"];
const POLICY_PANE_FIELDS = ["start_date", "end_date", "pipeline", "trust_fallback"];
const glassSx = (theme: any) => ({
backgroundColor: alpha(theme.palette.background.default, 0.72),
backdropFilter: "blur(8px)",
borderColor: "divider",
boxShadow: 1,
});
function GlassPanel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Paper variant="outlined" sx={(theme) => ({ p: 3, borderRadius: 3, ...glassSx(theme) })}>
<Typography
variant="overline"
sx={{ display: "block", mb: 2, color: "text.secondary", letterSpacing: 1.2, lineHeight: 1.4 }}
>
{title}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>{children}</Box>
</Paper>
);
}
function FetchRequestList() { function FetchRequestList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { list, remove, resource } = useResource("fetch-requests"); const { list, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext(); const { resources: allResources } = useAppContext();
const [rows, setRows] = useState<any[] | null>(null); const [rows, setRows] = useState<any[] | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { showToast } = useToast();
const reload = () => {
if (!resource) return;
list({ limit: 20 }).then((res) => setRows(res.items ?? []));
};
const columns = useMemo(() => { const columns = useMemo(() => {
if (!resource) return []; if (!resource) return [];
@@ -68,111 +27,44 @@ function FetchRequestList() {
list({ limit: 20 }).then((res) => setRows(res.items ?? [])); list({ limit: 20 }).then((res) => setRows(res.items ?? []));
}, [resource?.name]); }, [resource?.name]);
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (
!window.confirm(
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
)
)
return;
setDeletingId(id);
try {
await remove(id);
showToast("Fetch request deleted");
reload();
} catch (err: any) {
showToast(formatApiError(err), "error");
} finally {
setDeletingId(null);
}
};
if (!rows) { if (!rows) {
return ( return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}> <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress size={28} /> <CircularProgress size={24} />
</Box> </Box>
); );
} }
if (rows.length === 0) { if (rows.length === 0) {
return ( return <Typography variant="body2" color="text.secondary">No fetch requests found.</Typography>;
<EmptyState
icon={<ReceiptLongIcon />}
title="No fetch requests yet"
description="Upload a bank statement or configure email ingestion to import your first transactions."
actionLabel="New Fetch Request"
onAction={() => document.getElementById("new-fetch-request")?.scrollIntoView({ behavior: "smooth", block: "center" })}
/>
);
} }
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{rows.map((row, i) => { {rows.map((row, i) => {
const displayFormat = resource?.displayFormat ?? ""; const displayFormat = resource?.displayFormat ?? "";
return ( return (
<Paper <Paper
key={row.id ?? i} key={row.id ?? i}
variant="outlined" variant="outlined"
sx={(theme) => ({ sx={{ p: 2, cursor: "pointer", "&:hover": { borderColor: "primary.main" } }}
p: 2,
cursor: "pointer",
borderRadius: 3,
transition: "border-color 160ms ease, box-shadow 160ms ease, background-color 160ms ease",
"&:hover": {
borderColor: "primary.main",
boxShadow: 1,
backgroundColor: alpha(theme.palette.background.default, 0.9),
},
...glassSx(theme),
})}
onClick={() => navigate(`/fetch-requests/${row.id}`)} onClick={() => navigate(`/fetch-requests/${row.id}`)}
> >
<Box sx={{ display: "flex", gap: 2, alignItems: "center" }}> <Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", alignItems: "center", flex: 1, minWidth: 0 }}> {columns.map((col) => (
{columns <Box key={col.name} sx={{ minWidth: 120 }}>
.filter((col) => col.name !== "created_at") <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
.map((col) => ( {col.label}
<Box key={col.name} sx={{ minWidth: 120 }}> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> <ListCellRenderer
{col.label} field={col}
</Typography> value={row[col.name]}
<ListCellRenderer displayFormat={col.fk
field={col} ? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? displayFormat)
value={row[col.name]} : displayFormat}
displayFormat={col.fk />
? (allResources.find((r) => r.name === col.fk!.resource)?.displayFormat ?? displayFormat) </Box>
: displayFormat} ))}
/>
</Box>
))}
</Box>
{(() => {
const dateCol = columns.find((col) => col.name === "created_at");
if (!dateCol) return null;
return (
<Box sx={{ flexShrink: 0, textAlign: "right" }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{dateCol.label}
</Typography>
<ListCellRenderer field={dateCol} value={row.created_at} displayFormat={displayFormat} />
</Box>
);
})()}
<Box sx={{ flexShrink: 0 }}>
<Tooltip title="Delete fetch request">
<IconButton
size="small"
color="error"
disabled={deletingId === row.id || row.status === "processing"}
onClick={(e) => handleDelete(e, row.id)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box> </Box>
</Paper> </Paper>
); );
@@ -185,7 +77,6 @@ export default function FetchRequestCreate() {
const { resources: allResources } = useAppContext(); const { resources: allResources } = useAppContext();
const resource = useMemo(() => allResources.find((r) => r.name === "fetch-requests"), [allResources]); const resource = useMemo(() => allResources.find((r) => r.name === "fetch-requests"), [allResources]);
const { create } = useResource("fetch-requests"); const { create } = useResource("fetch-requests");
const navigate = useNavigate();
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({}); const [fkOptions, setFkOptions] = useState<Record<string, { value: any; label: string }[]>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -235,10 +126,6 @@ export default function FetchRequestCreate() {
const display = applyDisplayFormat(created, resource!.displayFormat); const display = applyDisplayFormat(created, resource!.displayFormat);
setResult({ severity: "success", message: `Created: ${display}` }); setResult({ severity: "success", message: `Created: ${display}` });
setFormData({}); setFormData({});
const newId = (created as any)?.id;
if (newId) {
navigate(`/fetch-requests/${newId}`);
}
} catch (e: any) { } catch (e: any) {
const detail = e?.response?.data?.detail; const detail = e?.response?.data?.detail;
const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join("; ") : (detail ?? e?.message ?? "Unknown error"); const msg = Array.isArray(detail) ? detail.map((d: any) => d.msg).join("; ") : (detail ?? e?.message ?? "Unknown error");
@@ -256,90 +143,52 @@ export default function FetchRequestCreate() {
); );
} }
const renderPaneFields = (names: string[]) =>
names.map((name) => {
const field = formFields.find((f) => f.name === name);
if (!field) return null;
return (
<FormFieldRenderer
key={field.name}
field={field}
value={formData[field.name] ?? ""}
onChange={(val) => handleChange(field.name, val)}
fkOptions={fkOptions[field.name]}
/>
);
});
return ( return (
<Box sx={{ position: "relative" }}> <Container maxWidth="lg" sx={{ py: 4 }}>
{/* Decorative gradient backdrop — gives the frosted panes something to blur. */} <Typography variant="h5" fontWeight={800} gutterBottom>
<Box Fetch Requests
aria-hidden </Typography>
sx={{
position: "fixed",
inset: 0,
zIndex: 0,
pointerEvents: "none",
background: (theme) =>
[
`radial-gradient(640px circle at 12% 8%, ${alpha(theme.palette.primary.main, 0.14)}, transparent 70%)`,
`radial-gradient(900px circle at 88% 92%, ${alpha(theme.palette.primary.main, 0.09)}, transparent 70%)`,
`radial-gradient(520px circle at 78% 22%, ${alpha(theme.palette.primary.light, 0.07)}, transparent 70%)`,
].join(", "),
}}
/>
<Container maxWidth="lg" sx={{ py: 4, position: "relative", zIndex: 1 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests" }]}
title="Fetch Requests"
subtitle="Import transactions from bank statements or email and track pipeline progress."
/>
<Box id="new-fetch-request" sx={{ mb: 4 }}> <Paper variant="outlined" sx={{ p: 3, mb: 4, borderRadius: 2, position: "relative", overflow: "hidden", "&::before": { content: '""', position: "absolute", left: 0, top: 0, bottom: 0, width: 4, bgcolor: "primary.main" } }}>
<Typography variant="subtitle1" fontWeight={700} gutterBottom> <Box sx={{ ml: 0.5, mb: 2.5 }}>
<Typography variant="subtitle1" fontWeight={700}>
New Fetch Request New Fetch Request
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5, maxWidth: 560, lineHeight: 1.6 }}>
Choose an account, pipeline, and a file or email source to kick off an import.
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" },
gap: 2,
}}
>
<GlassPanel title="Source & Account">
{renderPaneFields(SOURCE_PANE_FIELDS)}
{renderPaneFields(["source"])}
</GlassPanel>
<GlassPanel title="Window & Policy">{renderPaneFields(POLICY_PANE_FIELDS)}</GlassPanel>
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 3, gap: 1.5 }}>
<Button variant="outlined" onClick={() => { setFormData({}); setResult(null); }}>
Reset
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={loading} sx={{ minWidth: 180 }}>
{loading ? <CircularProgress size={20} sx={{ mr: 0.5 }} /> : null}
Create Fetch Request
</Button>
</Box>
{result && (
<Alert severity={result.severity} sx={{ mt: 2 }} onClose={() => setResult(null)}>
{result.message}
</Alert>
)}
</Box> </Box>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2, maxWidth: 480 }}>
Recent Fetch Requests {formFields.map((field) => (
</Typography> <FormFieldRenderer
<FetchRequestList /> key={field.name}
</Container> field={field}
</Box> value={formData[field.name] ?? ""}
onChange={(val) => handleChange(field.name, val)}
fkOptions={fkOptions[field.name]}
/>
))}
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2, gap: 1 }}>
<Button variant="outlined" onClick={() => { setFormData({}); setResult(null); }}>
Reset
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={loading}>
{loading ? <CircularProgress size={20} sx={{ mr: 0.5 }} /> : null}
Create Fetch Request
</Button>
</Box>
{result && (
<Alert severity={result.severity} sx={{ mt: 1 }} onClose={() => setResult(null)}>
{result.message}
</Alert>
)}
</Paper>
<Typography variant="h6" fontWeight={700} sx={{ mb: 2 }}>
Recent Fetch Requests
</Typography>
<FetchRequestList />
</Container>
); );
} }

View File

@@ -9,9 +9,10 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Alert, Alert,
Divider, IconButton,
Tooltip, Snackbar,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ReplayIcon from "@mui/icons-material/Replay"; import ReplayIcon from "@mui/icons-material/Replay";
import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error"; import ErrorIcon from "@mui/icons-material/Error";
@@ -19,15 +20,12 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber";
import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline"; import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import { useResource, useItemSse, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi";
import { useResource, useItemSse, useAppContext, DetailFieldRenderer, applyDisplayFormat } from "../../react-openapi"; import { useQuery, useMutation } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { RETRY_MAX, formatApiError } from "../features/fetch-requests"; import { RETRY_MAX, formatApiError } from "../features/fetch-requests";
import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests"; import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests";
import { PipelineStepper } from "./components/PipelineStepper"; import { PipelineStepper } from "./components/PipelineStepper";
import { AmbiguityResolver } from "./components/AmbiguityResolver"; import { AmbiguityResolver } from "./components/AmbiguityResolver";
import { PageHeader } from "../ui/PageHeader";
import { useToast } from "../ui/Toast";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = { const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default", pending: "default",
@@ -70,30 +68,13 @@ function sseIcon(status: SSEEvent["status"]) {
} }
} }
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<Paper sx={{ p: 3, borderRadius: 3, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
<Typography variant="subtitle1" fontWeight={700} sx={{ flex: 1 }}>
{title}
</Typography>
{action}
</Box>
{children}
</Paper>
);
}
export default function FetchRequestDetail() { export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { get, patch, remove, resource } = useResource("fetch-requests"); const { get, update, resource } = useResource("fetch-requests");
const { resources: allResources } = useAppContext();
const [stepStats, setStepStats] = useState<Record<string, number>>({}); const [stepStats, setStepStats] = useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined); const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
const [retrying, setRetrying] = useState(false); const [failNotif, setFailNotif] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const { showToast } = useToast();
const feedRef = useRef<HTMLDivElement>(null); const feedRef = useRef<HTMLDivElement>(null);
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({ const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
@@ -102,6 +83,10 @@ export default function FetchRequestDetail() {
enabled: !!id, enabled: !!id,
}); });
const updateMutation = useMutation({
mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data),
});
const sseUrl = id ? `/fetch-requests/${id}/events` : null; const sseUrl = id ? `/fetch-requests/${id}/events` : null;
const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, { const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
onEvent: (parsed: SSEEvent) => { onEvent: (parsed: SSEEvent) => {
@@ -127,7 +112,7 @@ export default function FetchRequestDetail() {
refetchRequest(); refetchRequest();
} }
if (parsed.status === "failed") { if (parsed.status === "failed") {
showToast(parsed.message.error || "Fetch request failed", "error"); setFailNotif(parsed.message.error || "Fetch request failed");
refetchRequest(); refetchRequest();
} }
if (parsed.status === "completed" || parsed.step === "resume_extract") { if (parsed.status === "completed" || parsed.step === "resume_extract") {
@@ -165,61 +150,15 @@ export default function FetchRequestDetail() {
}, [sseEvents]); }, [sseEvents]);
const handleRetry = async () => { const handleRetry = async () => {
if (!id || !patch || retrying) return; if (!id) return;
setRetrying(true);
try { try {
await patch(id, { status: "pending" }); await updateMutation.mutateAsync({ id, data: { status: "pending" } });
refetchRequest(); refetchRequest();
} catch (err: any) { } catch (err: any) {
showToast(formatApiError(err), "error"); setFailNotif(formatApiError(err));
} finally {
setRetrying(false);
} }
}; };
const handleDelete = async () => {
if (!id || !remove || deleting) return;
if (
!window.confirm(
"Delete this fetch request along with all its expenses and ambiguities? This cannot be undone.",
)
)
return;
setDeleting(true);
try {
await remove(id);
showToast("Fetch request deleted");
navigate("/fetch-requests");
} catch (err: any) {
showToast(formatApiError(err), "error");
} finally {
setDeleting(false);
}
};
const req = fetchRequest as any;
const retryCount = req?.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX;
const status = (req?.status ?? "pending") as FetchRequestStatus;
const detailFields = (resource?.orderedFields ?? []).filter(
(f) => f.name !== "source",
);
const displayTitle = useMemo(() => {
if (!resource || !req) return "";
const resolved = Object.fromEntries(
resource.orderedFields.map((field) => {
const value = req[field.name];
if (field.fk && typeof value === "object" && value != null) {
const target = allResources.find((r) => r.name === field.fk!.resource);
if (target) return [field.name, applyDisplayFormat(value, target.displayFormat)];
}
return [field.name, value];
}),
);
return applyDisplayFormat(resolved, resource.displayFormat);
}, [resource, req, allResources]);
if (isLoading) { if (isLoading) {
return ( return (
<Box sx={{ display: "flex", justifyContent: "center", p: 8 }}> <Box sx={{ display: "flex", justifyContent: "center", p: 8 }}>
@@ -231,67 +170,56 @@ export default function FetchRequestDetail() {
if (fetchError || !fetchRequest) { if (fetchError || !fetchRequest) {
return ( return (
<Container sx={{ mt: 4 }}> <Container sx={{ mt: 4 }}>
<PageHeader <Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests", path: "/fetch-requests" }, { label: "Details" }]} Back
title="Fetch Request" </Button>
/>
<Alert severity="error">Failed to load fetch request</Alert> <Alert severity="error">Failed to load fetch request</Alert>
</Container> </Container>
); );
} }
return ( const req = fetchRequest as any;
<Container maxWidth="lg" sx={{ py: 4 }}> const retryCount = req.retry_count ?? 0;
<PageHeader const isRetryExhausted = retryCount >= RETRY_MAX;
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests", path: "/fetch-requests" }, { label: displayTitle }]} const status = req.status as FetchRequestStatus;
title={displayTitle}
subtitle="Live pipeline status, progress events, and ambiguity resolution for this import."
/>
<Section const detailFields = (resource?.orderedFields ?? []).filter(
title="Overview" (f) => f.name !== "source",
action={ );
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 <Chip
icon={statusIcons[status] as any} icon={statusIcons[status] as any}
label={status.replace(/_/g, " ")} label={status.replace(/_/g, " ")}
color={statusColors[status]} color={statusColors[status]}
sx={{ textTransform: "capitalize" }}
/> />
} <Typography variant="h6" fontWeight={600}>{req.account_name}</Typography>
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip <Chip
label={"path" in (req.source ?? {}) ? "File" : "Email"} label={"path" in (req.source ?? {}) ? "File" : "Email"}
size="small" size="small"
variant="outlined" variant="outlined"
color={"path" in (req.source ?? {}) ? "primary" : "secondary"} color={"path" in (req.source ?? {}) ? "primary" : "secondary"}
/> />
<Typography variant="caption" color="text.secondary">
ID: {req.id}
</Typography>
</Box> </Box>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}> <Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
{detailFields.map((field) => { {detailFields.map((field) => (
const value = req[field.name]; <DetailFieldRenderer
let fmt = resource?.displayFormat; key={field.name}
if (field.fk && typeof value === "object" && value != null) { field={field}
const target = allResources.find((r) => r.name === field.fk!.resource); value={req[field.name]}
if (target) fmt = target.displayFormat; displayFormat={resource?.displayFormat}
} />
return ( ))}
<DetailFieldRenderer
key={field.name}
field={field}
value={value}
displayFormat={fmt}
/>
);
})}
</Box> </Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}> <Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
@@ -304,27 +232,13 @@ export default function FetchRequestDetail() {
size="small" size="small"
startIcon={<ReplayIcon />} startIcon={<ReplayIcon />}
onClick={handleRetry} onClick={handleRetry}
disabled={retrying} disabled={updateMutation.isPending}
> >
Retry Retry
</Button> </Button>
)} )}
<Tooltip title={status === "processing" ? "Cannot delete while processing" : "Delete fetch request"}>
<span>
<Button
variant="outlined"
size="small"
color="error"
startIcon={<DeleteOutlineIcon />}
onClick={handleDelete}
disabled={deleting || status === "processing"}
>
Delete
</Button>
</span>
</Tooltip>
</Box> </Box>
</Section> </Paper>
{status === "failed" && req.error_message && ( {status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}> <Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
@@ -344,33 +258,33 @@ export default function FetchRequestDetail() {
liveParsedCount={liveParsedCount ?? 0} liveParsedCount={liveParsedCount ?? 0}
/> />
<Section <Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
title="Progress Events" <Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
action={ <Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> Progress Events
<Box </Typography>
sx={{ <Box
width: 8, sx={{
height: 8, width: 10,
borderRadius: "50%", height: 10,
bgcolor: sseConnected ? "success.main" : "error.main", borderRadius: "50%",
}} bgcolor: sseConnected ? "success.main" : "error.main",
/> flexShrink: 0,
<Typography variant="caption" color="text.secondary"> }}
{sseConnected ? "Connected" : "Disconnected"} />
</Typography> <Typography variant="caption" color="text.secondary">
</Box> {sseConnected ? "Connected" : "Disconnected"}
} </Typography>
> </Box>
<Box <Box
ref={feedRef} ref={feedRef}
sx={{ sx={{
maxHeight: 300, maxHeight: 300,
overflowY: "auto", overflowY: "auto",
p: 2,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 1, gap: 1,
pr: 0.5,
}} }}
> >
{displayEvents.length === 0 ? ( {displayEvents.length === 0 ? (
@@ -385,7 +299,7 @@ export default function FetchRequestDetail() {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 1.5, gap: 1.5,
p: 1.25, p: 1,
borderRadius: 2, borderRadius: 2,
bgcolor: "action.hover", bgcolor: "action.hover",
}} }}
@@ -405,9 +319,20 @@ export default function FetchRequestDetail() {
)) ))
)} )}
</Box> </Box>
</Section> </Paper>
<AmbiguityResolver fetchRequestId={id!} /> <AmbiguityResolver fetchRequestId={id!} />
<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> </Container>
); );
} }

View File

@@ -29,12 +29,7 @@ function computeProgressPercent(
let pct = 0; let pct = 0;
if ( if (seenSteps.has("raw_lines") || seenSteps.has("txn_blocks")) pct += 10;
seenSteps.has("raw_lines") ||
seenSteps.has("txn_blocks")
) {
pct += 10;
}
if (txnBlockCount > 0) { if (txnBlockCount > 0) {
const current = Math.max(liveCount, stepStats.txn_dicts ?? 0); const current = Math.max(liveCount, stepStats.txn_dicts ?? 0);

642
src/FetchRequestDetail.tsx Normal file
View File

@@ -0,0 +1,642 @@
import * as React from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Container,
Paper,
Typography,
Button,
Chip,
CircularProgress,
Alert,
Stepper,
Step,
StepLabel,
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 { useAppContext, useResource, useItemSse } from "../react-openapi";
import { useQuery, useMutation } from "@tanstack/react-query";
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 }} />,
};
const stepLabels = ["Extract", "Raw Expense", "Enrich", "Save"];
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));
}
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" }} />;
}
}
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { get, update } = useResource("fetch-requests");
const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({
queryKey: ["fetch-requests", "detail", id],
queryFn: () => get(id!),
enabled: !!id,
});
const updateMutation = useMutation({
mutationFn: ({ id: rid, data }: { id: string; data: any }) => update(rid, data),
});
const resolveMutation = useResolveAmbiguity();
const { data: ambiguities, refetch: refetchAmbiguities } = useFetchRequestAmbiguities(id!);
const [stepStats, setStepStats] = React.useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = React.useState<number | undefined>(undefined);
const [failNotif, setFailNotif] = React.useState<string | null>(null);
const feedRef = React.useRef<HTMLDivElement>(null);
const sseUrl = id ? `/fetch-requests/${id}/events` : null;
const { connected: sseConnected, events: sseEvents } = useItemSse(sseUrl, {
onEvent: (parsed: SSEEvent) => {
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();
}
},
});
React.useEffect(() => {
if (feedRef.current) {
feedRef.current.scrollTop = feedRef.current.scrollHeight;
}
}, [sseEvents]);
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 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 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]);
const progressPercent = React.useMemo(
() => computeProgressPercent(
(fetchRequest as any)?.status as FetchRequestStatus ?? "pending",
displayParsedCount,
seenSteps,
stepStats,
txnBlockCount,
txnDictCount,
),
[fetchRequest, displayParsedCount, seenSteps, stepStats, txnBlockCount, txnDictCount],
);
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 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 hasAmbiguities = ambiguities && ambiguities.length > 0;
const allResolved = hasAmbiguities && ambiguities.every((a: any) => a.status === "resolved");
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back to Fetch Requests
</Button>
{/* Header Card */}
<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.start_date ? new Date(req.start_date).toLocaleDateString() : "?"} → {req.end_date ? new Date(req.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>
{/* Progress Bar */}
<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>
{/* Retry Counter */}
<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>
{/* Error Alerts */}
{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>
)}
{/* Pipeline Stepper */}
<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>;
}
return (
<Step key={label}>
<StepLabel
StepIconComponent={() => <Box sx={{ display: "flex", alignItems: "center" }}>{icon}</Box>}
>
<Typography variant="body2" fontWeight={600}>{label}</Typography>
{stepMessages[index] && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", lineHeight: 1.2 }}>
{stepMessages[index]}
</Typography>
)}
</StepLabel>
</Step>
);
})}
</Stepper>
</Paper>
{/* SSE Event Feed */}
<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>
</Box>
))
)}
</Box>
</Paper>
{/* Ambiguity Resolution */}
{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>
);
}

509
src/FetchRequests.tsx Normal file
View File

@@ -0,0 +1,509 @@
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,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} 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 { useResource, FormFieldRenderer, applyDisplayFormat } from "../react-openapi";
import type { FieldConfig } from "../react-openapi";
import { useMutation, useQuery } from "@tanstack/react-query";
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" }} />,
};
const STATUS_OPTIONS: FetchRequestStatus[] = [
"pending",
"processing",
"paused",
"raw_expenses_done",
"enriched_done",
"completed",
"failed",
];
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 { list, create, update, remove, resource: fetchRes } = useResource("fetch-requests");
const { data: listData, isLoading, isFetching, refetch } = useQuery({
queryKey: ["fetch-requests", "list", { statusFilter, accountFilter, sourceFilter }],
queryFn: () => list({
...(statusFilter.length > 0 ? { status: statusFilter.join(",") } : {}),
...(accountFilter ? { account_name: accountFilter } : {}),
...(sourceFilter !== "all" ? { source_type: sourceFilter } : {}),
}),
});
const { list: listAccounts } = useResource("accounts");
const { data: accountsData } = useQuery({
queryKey: ["accounts", "list"],
queryFn: () => listAccounts(),
});
const accountOptions: string[] = React.useMemo(() => {
return (accountsData?.items ?? []).map((a: any) => a.name).filter(Boolean);
}, [accountsData]);
const fields = fetchRes?.orderedFields ?? [];
const formatField: FieldConfig | undefined = fields.find(f => f.name === "format");
const startDateField: FieldConfig | undefined = fields.find(f => f.name === "start_date");
const endDateField: FieldConfig | undefined = fields.find(f => f.name === "end_date");
const payorUsernameField: FieldConfig | undefined = fields.find(f => f.name === "payor_username");
const createMutation = useMutation({ mutationFn: (data: any) => create(data) });
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => update(id, data),
});
const deleteMutation = useMutation({ mutationFn: (id: string) => remove(id) });
const uploadMutation = useUploadFile();
const requests = listData?.items ?? [];
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 — 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);
};
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline
</Typography>
{/* Create Form */}
<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 && (
<FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
)}
</>
) : (
<>
{formatField && (
<FormFieldRenderer field={formatField} value={format} onChange={setFormat} />
)}
<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 />
)}
/>
{payorUsernameField && (
<FormFieldRenderer field={payorUsernameField} value={payorUsername} onChange={setPayorUsername} />
)}
<Box sx={{ display: "flex", gap: 2 }}>
{startDateField && (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer field={startDateField} value={startDate} onChange={setStartDate} />
</Box>
)}
{endDateField && (
<Box sx={{ flex: 1 }}>
<FormFieldRenderer field={endDateField} value={endDate} onChange={setEndDate} />
</Box>
)}
</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>
{/* Filters */}
<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(", ")}
>
{STATUS_OPTIONS.map((s) => (
<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 }}
/>
<ToggleButtonGroup
value={sourceFilter}
exclusive
onChange={(_, val) => val && setSourceFilter(val)}
size="small"
>
<ToggleButton value="all">All</ToggleButton>
<ToggleButton value="file">File</ToggleButton>
<ToggleButton value="email">Email</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ flex: 1 }} />
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
</Paper>
{/* List Table */}
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : requests.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No fetch requests yet
</Box>
) : (
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 4 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Account</TableCell>
<TableCell>Source</TableCell>
<TableCell>Date Range</TableCell>
<TableCell>Status</TableCell>
<TableCell>Retries</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{[...requests]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((req: FetchRequest) => (
<TableRow
key={req.id}
hover
sx={{ cursor: "pointer" }}
onClick={() => navigate(`/fetch-requests/${req.id}`)}
>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{shortId(req.fingerprint)}
</Typography>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(req.fingerprint);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</TableCell>
<TableCell>{req.account_name}</TableCell>
<TableCell>
<Chip
label={"path" in req.source ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in req.source ? "primary" : "secondary"}
/>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{(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>
</TableCell>
<TableCell>
<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>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
{(req.retry_count ?? 0) > 0 ? `${req.retry_count}/${RETRY_MAX}` : "—"}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontSize: "0.8rem", whiteSpace: "nowrap" }}>
{new Date(req.created_at).toLocaleString()}
</Typography>
</TableCell>
<TableCell align="right">
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{req.status === "paused" && (
<Tooltip title="Resolve ambiguities">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); navigate(`/fetch-requests/${req.id}`); }}>
<WarningAmberIcon fontSize="small" color="warning" />
</IconButton>
</Tooltip>
)}
{req.status === "failed" && (req.retry_count ?? 0) < RETRY_MAX && (
<Tooltip title="Retry">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); handleRetry(req); }}>
<ReplayIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setDeleteTarget(req); }}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
<Snackbar
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

@@ -1,36 +1,52 @@
import * as React from 'react'; import * as React from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import {AppBar, Box, Button, IconButton, Toolbar, Tooltip} from "@mui/material";
import MenuIcon from "@mui/icons-material/Menu";
import LogoutIcon from "@mui/icons-material/Logout";
function Copyright() {
return (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
<Link color="text.secondary" href="https://www.aetoskia.com/">
{'Copyright © Aetoskia Internal Infrastructure — All rights reserved.'}
</Link>
&nbsp;
{new Date().getFullYear()}
</Typography>
);
}
export default function Footer() { export default function Footer() {
return ( return (
<Box <AppBar
component="footer" position="fixed"
color="default"
sx={{ sx={{
top: 'auto',
bottom: 0,
backdropFilter: 'blur(8px)',
boxShadow: 'none',
borderTop: '1px solid', borderTop: '1px solid',
borderColor: 'divider', borderColor: 'divider',
py: 3,
mt: 'auto',
}} }}
> >
<Container <Toolbar>
maxWidth="lg" <React.Fragment>
sx={{ <Container
display: 'flex', sx={{
alignItems: 'center', display: 'flex',
justifyContent: 'space-between', flexDirection: 'column',
gap: 2, alignItems: 'center',
flexWrap: 'wrap', gap: { xs: 2, sm: 4 },
}} textAlign: { sm: 'center', md: 'left' },
> }}
<Typography variant="body2" color="text.secondary" fontWeight={500}> >
Khata <Copyright />
</Typography> </Container>
<Typography variant="caption" color="text.secondary"> </React.Fragment>
© {new Date().getFullYear()} Aetoskia Internal Infrastructure — All rights reserved. </Toolbar>
</Typography> </AppBar>
</Container>
</Box>
); );
} }

View File

@@ -1,5 +1,8 @@
import * as React from "react"; import * as React from "react";
import { useLocation, matchPath, useNavigate } from "react-router-dom"; import {
useLocation,
matchPath
} from "react-router-dom";
import { import {
AppBar, AppBar,
Toolbar, Toolbar,
@@ -8,11 +11,6 @@ import {
Tooltip, Tooltip,
Button, Button,
Box, Box,
Drawer,
List,
ListItemButton,
ListItemText,
Divider,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
@@ -20,6 +18,7 @@ import MenuIcon from "@mui/icons-material/Menu";
import LogoutIcon from "@mui/icons-material/Logout"; import LogoutIcon from "@mui/icons-material/Logout";
import DarkModeIcon from "@mui/icons-material/DarkMode"; import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode"; import LightModeIcon from "@mui/icons-material/LightMode";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../react-auth"; import { useAuth } from "../react-auth";
import { ColorModeContext } from "./shared-theme/AppTheme"; import { ColorModeContext } from "./shared-theme/AppTheme";
@@ -31,261 +30,134 @@ interface HeaderProps {
onDrawerToggle?: () => void; onDrawerToggle?: () => void;
} }
const NAV_LINKS = [ export default function Header({
{ label: "Home", path: "/" }, routerMapping,
{ label: "Expenses", path: "/expenses" }, onDrawerToggle,
{ label: "Reports", path: "/reports" }, }: HeaderProps) {
{ label: "Fetch Requests", path: "/fetch-requests" },
];
function isActive(path: string, locationPath: string): boolean {
if (path === "/") return locationPath === "/" || locationPath === "/home";
return matchPath({ path, end: false }, locationPath) !== null;
}
export default function Header({ routerMapping, onDrawerToggle }: HeaderProps) {
const location = useLocation(); const location = useLocation();
const matchedRoute = routerMapping.find((route) =>
matchPath({ path: route.path, end: false }, location.pathname)
);
const headerTitle = matchedRoute?.headerTitle ?? "Khata";
const navigate = useNavigate(); const navigate = useNavigate();
const { currentUser, logout } = useAuth(); const { currentUser, logout } = useAuth();
const { mode, toggleColorMode } = React.useContext(ColorModeContext); const { mode, toggleColorMode } = React.useContext(ColorModeContext);
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md")); const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const [mobileOpen, setMobileOpen] = React.useState(false);
const isAuthenticated = !!currentUser; const isAuthenticated = !!currentUser;
const navContent = ( return (
<Box <AppBar
position="fixed"
color="default"
sx={{ sx={{
display: { xs: "none", md: "flex" }, zIndex: (theme) => theme.zIndex.drawer + 1,
alignItems: "center", backdropFilter: "blur(8px)",
gap: 0.5, boxShadow: "none",
ml: 3, borderBottom: "1px solid",
borderColor: "divider",
}} }}
> >
{NAV_LINKS.map((link) => { <Toolbar>
const active = isActive(link.path, location.pathname); {/* MOBILE MENU BUTTON */}
return ( {isMobile && onDrawerToggle && (
<Button <IconButton
key={link.path}
color="inherit" color="inherit"
onClick={() => navigate(link.path)} edge="start"
sx={{ onClick={onDrawerToggle}
textTransform: "none", sx={{ mr: 2 }}
fontWeight: active ? 600 : 500,
color: active ? "text.primary" : "text.secondary",
px: 1.5,
borderRadius: "6px",
position: "relative",
"&::after":
active
? {
content: '""',
position: "absolute",
left: 12,
right: 12,
bottom: 4,
height: 2,
borderRadius: 1,
backgroundColor: "primary.main",
}
: {},
"&:hover": { backgroundColor: "action.hover", color: "text.primary" },
}}
> >
{link.label} <MenuIcon />
</Button> </IconButton>
); )}
})}
</Box>
);
const authSection = ( {/* THEME TOGGLE */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <IconButton onClick={toggleColorMode} color="inherit" sx={{ mr: 2 }}>
{isAuthenticated ? ( {mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
<> </IconButton>
<Button
color="inherit" {/* TITLE */}
onClick={() => navigate("/admin")} <Typography
sx={{ variant="h6"
textTransform: "none", noWrap
fontWeight: isActive("/admin", location.pathname) ? 600 : 500, sx={{ fontWeight: "bold", cursor: "pointer" }}
color: isActive("/admin", location.pathname) ? "text.primary" : "text.secondary", onClick={() => navigate("/")}
display: { xs: "none", sm: "inline-flex" },
}}
>
Admin
</Button>
<Button
color="inherit"
onClick={() => navigate("/profile/me")}
sx={{ textTransform: "none", fontWeight: 500 }}
>
{currentUser.username}
</Button>
<Tooltip title="Logout">
<IconButton color="inherit" onClick={logout} aria-label="Logout">
<LogoutIcon />
</IconButton>
</Tooltip>
</>
) : (
<Button
color="inherit"
variant="outlined"
onClick={() => navigate("/login")}
sx={{ textTransform: "none" }}
> >
Login {headerTitle}
</Button> </Typography>
)}
</Box>
);
const drawerContent = ( <span style={{ flexGrow: 1 }} />
<Box sx={{ width: 260 }} role="presentation">
<Box sx={{ p: 2 }}> {/* NAV LINKS */}
<Brand /> <Box
</Box> sx={{
<Divider /> display: { xs: "none", md: "flex" },
<List> alignItems: "center",
{routerMapping mr: 2,
.filter((r) => !r.path.startsWith("/login") && !r.path.startsWith("/register") && !r.path.startsWith("/profile")) gap: 1,
.map((route) => { }}
const active = isActive(route.path, location.pathname); >
return ( {[
<ListItemButton { label: "Fetch", path: "/fetch-requests" },
key={route.path} ].map(({ label, path }) => (
selected={active} <Button
onClick={() => { key={path}
navigate(route.path); color="inherit"
setMobileOpen(false); onClick={() => navigate(path)}
}} sx={{ textTransform: "none", fontWeight: 500, px: 1.5 }}
sx={{ size="small"
borderRadius: "6px", >
mx: 1, {label}
mb: 0.5, </Button>
"&.Mui-selected": { ))}
backgroundColor: "action.selected", </Box>
},
}} {/* AUTH SECTION */}
>
<ListItemText
primary={route.headerTitle}
primaryTypographyProps={{ fontWeight: active ? 600 : 500, fontSize: 14 }}
/>
</ListItemButton>
);
})}
<Divider sx={{ my: 1 }} />
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<ListItemButton onClick={() => navigate("/profile/me")} sx={{ borderRadius: "6px", mx: 1 }}> <Box
<ListItemText primary="Profile" primaryTypographyProps={{ fontSize: 14 }} /> sx={{
</ListItemButton> display: { xs: "none", sm: "flex" },
<ListItemButton onClick={logout} sx={{ borderRadius: "6px", mx: 1 }}> alignItems: "center",
<ListItemText primary="Logout" primaryTypographyProps={{ fontSize: 14 }} /> mr: 2,
</ListItemButton> }}
>
<Button
color="inherit"
onClick={() => navigate("/admin")}
sx={{ textTransform: "none", fontWeight: 500 }}
>
Admin
</Button>
<Button
color="inherit"
onClick={() => navigate("/profile/me")}
sx={{ textTransform: "none", fontWeight: 500 }}
>
{currentUser.username}
</Button>
</Box>
<Tooltip title="Logout">
<IconButton color="inherit" onClick={logout}>
<LogoutIcon />
</IconButton>
</Tooltip>
</> </>
) : ( ) : (
<ListItemButton onClick={() => navigate("/login")} sx={{ borderRadius: "6px", mx: 1 }}> <Button
<ListItemText primary="Login" primaryTypographyProps={{ fontSize: 14 }} /> color="inherit"
</ListItemButton> variant="outlined"
onClick={() => navigate("/login")}
sx={{ textTransform: "none" }}
>
Login
</Button>
)} )}
</List> </Toolbar>
</Box> </AppBar>
); );
}
return (
<>
<AppBar
position="sticky"
color="default"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
backdropFilter: "blur(8px)",
boxShadow: "none",
borderBottom: "1px solid",
borderColor: "divider",
backgroundColor: "background.default",
}}
>
<Toolbar>
{/* MOBILE MENU */}
{isMobile && (
<IconButton
color="inherit"
edge="start"
onClick={() => setMobileOpen(true)}
aria-label="Open navigation"
sx={{ mr: 1 }}
>
<MenuIcon />
</IconButton>
)}
{/* BRAND — always leftmost */}
<Brand onClick={() => navigate("/")} />
{navContent}
<Box sx={{ flexGrow: 1 }} />
{/* THEME TOGGLE — right side */}
<IconButton onClick={toggleColorMode} color="inherit" aria-label="Toggle color mode" sx={{ mr: 1 }}>
{mode === "dark" ? <LightModeIcon /> : <DarkModeIcon />}
</IconButton>
{authSection}
</Toolbar>
</AppBar>
<Drawer
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
>
{drawerContent}
</Drawer>
</>
);
}
function Brand({ onClick }: { onClick?: () => void }) {
return (
<Box
onClick={onClick}
sx={{
display: "flex",
alignItems: "center",
gap: 1,
cursor: onClick ? "pointer" : "default",
userSelect: "none",
}}
>
<Box
sx={{
width: 28,
height: 28,
borderRadius: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #635BFF 0%, #8B5CF6 100%)",
color: "#fff",
fontWeight: 700,
fontSize: 15,
}}
>
K
</Box>
<Typography
variant="h6"
sx={{ fontWeight: 700, letterSpacing: "-0.02em", color: "text.primary" }}
>
Khata
</Typography>
</Box>
);
}

View File

@@ -1,11 +1,11 @@
import * as React from "react"; import * as React from "react";
import { Box, Typography, Button, Container, Grid, Paper, alpha } from "@mui/material"; import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material";
import { useTheme } from "@mui/material/styles"; import { useTheme, alpha } from "@mui/material/styles";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; import DashboardIcon from "@mui/icons-material/Dashboard";
import SyncIcon from "@mui/icons-material/Sync"; import SyncIcon from "@mui/icons-material/Sync";
import AccountBalanceWalletIcon from "@mui/icons-material/AccountBalanceWallet"; import BarChartIcon from "@mui/icons-material/BarChart";
import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import SettingsIcon from "@mui/icons-material/Settings";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { useAuth } from "../react-auth"; import { useAuth } from "../react-auth";
@@ -14,9 +14,11 @@ interface FeatureCardProps {
title: string; title: string;
description: string; description: string;
path: string; path: string;
label?: string;
accent: string;
} }
function FeatureCard({ icon, title, description, path }: FeatureCardProps) { function FeatureCard({ icon, title, description, path, label, accent }: FeatureCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
@@ -33,154 +35,206 @@ function FeatureCard({ icon, title, description, path }: FeatureCardProps) {
height: "100%", height: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
transition: "border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease", 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": { "&:hover": {
transform: "translateY(-4px)",
boxShadow: `0 12px 32px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.3 : 0.08)}`,
borderColor: "transparent", borderColor: "transparent",
transform: "translateY(-2px)", "&::before": { opacity: 1 },
boxShadow: `0 8px 24px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.35 : 0.08)}`,
"& .feature-arrow": {
opacity: 1,
transform: "translateX(0)",
},
}, },
}} }}
> >
<Box <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1.5 }}>
sx={{ <Box
width: 40, sx={{
height: 40, width: 40,
borderRadius: 2, height: 40,
display: "flex", borderRadius: 2,
alignItems: "center", display: "flex",
justifyContent: "center", alignItems: "center",
backgroundColor: "action.hover", justifyContent: "center",
color: "primary.main", background: alpha(accent, 0.12),
mb: 2, color: accent,
}} }}
> >
{icon} {icon}
</Box>
<Typography variant="subtitle1" fontWeight={700}>
{title}
</Typography>
</Box> </Box>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 0.75 }}>
{title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, lineHeight: 1.6 }}> <Typography variant="body2" color="text.secondary" sx={{ flex: 1, lineHeight: 1.6 }}>
{description} {description}
</Typography> </Typography>
<Box {label && (
className="feature-arrow" <Chip
sx={{ label={label}
mt: 2, size="small"
display: "flex", variant="outlined"
alignItems: "center", sx={{ mt: 2, alignSelf: "flex-start", textTransform: "capitalize" }}
gap: 0.5, />
color: "primary.main", )}
fontWeight: 600,
opacity: 0,
transform: "translateX(-4px)",
transition: "opacity 180ms ease, transform 180ms ease",
}}
>
<Typography variant="caption" fontWeight={600} color="primary">
Open
</Typography>
<ArrowForwardIcon sx={{ fontSize: 14 }} />
</Box>
</Paper> </Paper>
); );
} }
export default function Home() { export default function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme();
const { currentUser } = useAuth(); const { currentUser } = useAuth();
const features = [ const features = [
{ {
icon: <ReceiptLongIcon />, icon: <DashboardIcon />,
title: "Expenses", title: "Dashboard",
description: description: "Visualise inflows and outflows with interactive charts, drill into categories, and track trends over daily, weekly, and monthly periods.",
"Browse every transaction imported from your accounts — grouped by month, with entity, account, tags, and amount at a glance.", path: "/dashboard",
path: "/expenses", accent: theme.palette.mode === "dark" ? "#818cf8" : "#6366f1",
}, },
{ {
icon: <SyncIcon />, icon: <SyncIcon />,
title: "Fetch Requests", title: "Fetch Requests",
description: description: "Upload bank statements or configure email ingestion to auto-import transactions. Track pipeline status from pending through to completion.",
"Upload bank statements or configure email ingestion to auto-import transactions. Track pipeline status from pending through to completion.",
path: "/fetch-requests", path: "/fetch-requests",
accent: theme.palette.mode === "dark" ? "#34d399" : "#10b981",
}, },
{ {
icon: <AccountBalanceWalletIcon />, icon: <BarChartIcon />,
title: "Accounts & Entities", title: "Report Snapshots",
description: description: "Generate cached report snapshots with custom filters — accounts, date ranges, amount bounds — then pin a snapshot on the dashboard for consistent comparisons.",
"Manage your accounts, expenses, tags, and payors through the OpenAPI-driven admin panel with full CRUD and server-side filters.", 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", path: "/admin",
}, accent: theme.palette.mode === "dark" ? "#e879f9" : "#d946ef",
{
icon: <AccountCircleIcon />,
title: "Profile",
description:
"View and edit your user profile — username, email, and account preferences — from the profile page.",
path: "/profile/me",
}, },
]; ];
return ( return (
<Box sx={{ width: "100%" }}> <Box
<Container maxWidth="lg" sx={{ py: { xs: 6, md: 10 } }}> sx={{
<Box sx={{ maxWidth: 680, mx: { xs: "auto", md: 0 }, textAlign: { xs: "center", md: "left" } }}> minHeight: "calc(100vh - 64px)",
<Typography display: "flex",
variant="overline" flexDirection: "column",
sx={{ color: "primary.main", mb: 1.5, display: "block", fontWeight: 700 }} position: "relative",
> overflow: "hidden",
Khata · Financial Ledger "&::before": {
</Typography> content: '""',
position: "absolute",
top: "-15%",
left: "-8%",
width: "45%",
height: "55%",
background: "radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 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%)",
zIndex: 0,
},
}}
>
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1, flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", py: 6 }}>
<Box
sx={{
textAlign: "center",
mb: 6,
}}
>
<Typography <Typography
variant="h1" variant="h1"
sx={{ sx={{
color: "text.primary", fontWeight: 800,
fontSize: { xs: "2.25rem", md: "3.25rem" }, fontSize: { xs: "2.5rem", sm: "3.5rem", md: "5rem" },
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 50%, #f59e0b 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: "-0.03em", letterSpacing: "-0.03em",
mb: 2, mb: 2,
}} }}
> >
Your intelligent, extensible financial ledger. Welcome to Khata
</Typography> </Typography>
<Typography <Typography
variant="body1" variant="h6"
color="text.secondary" color="text.secondary"
sx={{ maxWidth: 540, lineHeight: 1.7, fontSize: "1.0625rem" }} sx={{
maxWidth: 580,
mx: "auto",
lineHeight: 1.7,
fontWeight: 400,
fontSize: { xs: "1rem", md: "1.15rem" },
}}
> >
Import transactions from bank statements, enrich them with entity and tag intelligence, Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow.
and stay on top of your cashflow — all in one place.
</Typography> </Typography>
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: { xs: "center", md: "flex-start" }, flexWrap: "wrap" }}> <Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: "center", flexWrap: "wrap" }}>
<Button <Button
variant="contained" variant="contained"
size="large" size="large"
endIcon={<ArrowForwardIcon />} endIcon={<ArrowForwardIcon />}
onClick={() => navigate(currentUser ? "/fetch-requests" : "/login")} onClick={() => navigate("/dashboard")}
sx={{ px: 3.5, py: 1.4, fontWeight: 600 }} sx={{
px: 4,
py: 1.4,
borderRadius: "50px",
fontWeight: 700,
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)",
transition: "transform 0.2s ease, box-shadow 0.2s",
"&:hover": {
transform: "translateY(-2px)",
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.35)}`,
},
}}
> >
{currentUser ? "Enter App" : "Get Started"} Enter Dashboard
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
size="large" size="large"
onClick={() => navigate("/fetch-requests")} onClick={() => navigate("/fetch-requests")}
sx={{ px: 3.5, py: 1.4, fontWeight: 600 }} sx={{
px: 4,
py: 1.4,
borderRadius: "50px",
fontWeight: 600,
borderWidth: 2,
"&:hover": { borderWidth: 2 },
}}
> >
Import Data Import Data
</Button> </Button>
</Box> </Box>
</Box> </Box>
<Grid container spacing={3} sx={{ mt: { xs: 4, md: 8 } }}> <Grid container spacing={3}>
{features.map((f) => ( {features.map((f) => (
<Grid key={f.title} item xs={12} sm={6} md={3}> <Grid key={f.title} item xs={12} sm={6} md={3}>
<FeatureCard {...f} /> <FeatureCard {...f} />

View File

@@ -1,126 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Paper,
Typography,
Button,
Grid,
Alert,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import {
useResource,
useAppContext,
FormFieldRenderer,
extractFields,
useFkFieldOptions,
} from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
import { useToast } from "../ui/Toast";
import { apiErrorMessage } from "./types";
interface GenerateReportPanelProps {
onGenerated: (report: any) => void;
}
export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) {
const { schemas } = useAppContext();
const { create, loading: submitting } = useResource("reports");
const { showToast } = useToast();
const [formData, setFormData] = useState<Record<string, any>>({});
const [error, setError] = useState<string | null>(null);
const fields: FieldConfig[] = useMemo(() => {
const schema = schemas?.ReportQuery;
if (!schema) return [];
return extractFields("ReportQuery", schema, schemas).sort(
(a, b) => a.order - b.order || a.name.localeCompare(b.name)
);
}, [schemas]);
const { fkOptions, fkLoading, loadFkOnOpen } = useFkFieldOptions(fields);
useEffect(() => {
setFormData((prev) => {
const next = { ...prev };
let changed = false;
for (const f of fields) {
if (next[f.name] === undefined && f.defaultValue !== undefined) {
next[f.name] = Array.isArray(f.defaultValue) ? [...f.defaultValue] : f.defaultValue;
changed = true;
}
}
return changed ? next : prev;
});
}, [fields]);
const handleChange = useCallback((name: string, value: any) => {
setFormData((prev) => ({ ...prev, [name]: value }));
setError(null);
}, []);
const buildPayload = (): Record<string, any> => {
const payload: Record<string, any> = {};
for (const f of fields) {
const v = formData[f.name];
if (v === undefined || v === null || v === "") continue;
if (Array.isArray(v) && v.length === 0) continue;
payload[f.name] = v;
}
return payload;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
const created = await create(buildPayload());
showToast(`Generated snapshot ${created?.name ? `“${created.name}”` : ""}`.trim() || "Generated snapshot");
onGenerated(created);
} catch (e: any) {
setError(apiErrorMessage(e));
}
};
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3, mb: 4 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
Generate report
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>
Define the snapshot's scope. Granularity, payee and tag are sliced at view time — the cube is built once and
every combination stays cheap to read.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2, borderRadius: 2 }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={2}>
{fields.map((field) => (
<Grid item xs={12} sm={6} md={4} key={field.name}>
<FormFieldRenderer
field={field}
value={formData[field.name]}
onChange={(val) => handleChange(field.name, val)}
fkOptions={fkOptions[field.name]}
fkLoading={fkLoading[field.name]}
onFkOpen={loadFkOnOpen}
/>
</Grid>
))}
</Grid>
<Box sx={{ mt: 2, display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained" startIcon={<AddIcon />} disabled={submitting}>
{submitting ? "Generating…" : "Generate"}
</Button>
</Box>
</Box>
</Paper>
);
}

View File

@@ -1,184 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Container,
Box,
Paper,
Typography,
Alert,
Accordion,
AccordionSummary,
AccordionDetails,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import AssessmentIcon from "@mui/icons-material/Assessment";
import { useResource, useAppContext } from "../../react-openapi";
import { useToast } from "../ui/Toast";
import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState";
import { StatCard } from "../common/components/StatCard";
import { buildTxnFieldConfigs } from "../common/utils/fieldConfigs";
import { GenerateReportPanel } from "./GenerateReportPanel";
import { ReportList } from "./ReportList";
import { ReportViewer } from "./ReportViewer";
import { apiErrorMessage, buildReportFieldConfigs } from "./types";
export default function Report() {
const { resources } = useAppContext();
const { showToast } = useToast();
const { list, loading, error } = useResource("reports");
const { create, remove } = useResource("reports");
const [reports, setReports] = useState<any[] | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [viewerVersion, setViewerVersion] = useState(0);
const reportFields = useMemo(() => buildReportFieldConfigs(resources), [resources]);
const txnFields = useMemo(() => buildTxnFieldConfigs(resources), [resources]);
const load = useCallback(async () => {
const res = await list({ limit: 200 });
setReports(res.items ?? []);
}, [list]);
useEffect(() => {
load();
}, [load]);
const handleGenerated = useCallback(
(created: any) => {
load();
if (created?.id) setSelectedId(created.id);
},
[load],
);
const handleRegenerate = useCallback(
async (report: any) => {
try {
const created = await create(report.query ?? {});
showToast("Report regenerated");
setSelectedId(created?.id ?? report.id);
setViewerVersion((v) => v + 1);
load();
} catch (e: any) {
showToast(apiErrorMessage(e), "error");
}
},
[create, load, showToast],
);
const handleDelete = useCallback(
async (id: string) => {
try {
await remove(id);
showToast("Report deleted");
if (selectedId === id) setSelectedId(null);
load();
} catch (e: any) {
showToast(apiErrorMessage(e), "error");
}
},
[remove, load, selectedId, showToast],
);
const summary = useMemo(() => {
const rows = reports ?? [];
const granularities = new Set<string>();
for (const r of rows) for (const g of r.query?.granularities ?? []) granularities.add(g);
return { count: rows.length, granularities: [...granularities].join(", ") };
}, [reports]);
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Reports" }]}
title="Reports"
subtitle="Build an immutable snapshot cube once, then slice by granularity, period, payee and tag at view time."
/>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
Failed to load reports: {error}
</Alert>
)}
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 4 }}>
<StatCard label="Reports" value={summary.count.toLocaleString("en-IN")} />
<StatCard label="Granularities covered" value={summary.granularities || "—"} />
</Box>
<GenerateReportPanel onGenerated={handleGenerated} />
<Accordion
disableGutters
defaultExpanded
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
overflow: "hidden",
boxShadow: "none",
backgroundColor: "background.paper",
"&:before": { display: "none" },
mb: 4,
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
px: 2.5,
py: 1,
"& .MuiAccordionSummary-content": {
alignItems: "center",
gap: 1.5,
minWidth: 0,
},
}}
>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
Saved reports
</Typography>
{reports !== null && (
<Typography variant="caption" color="text.secondary">
{reports.length} report{reports.length === 1 ? "" : "s"}
</Typography>
)}
</AccordionSummary>
<AccordionDetails sx={{ px: 2.5, pb: 2.5, pt: 0 }}>
{reports !== null && reports.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 3 }}>
<EmptyState
icon={<AssessmentIcon />}
title="No reports yet"
description="Generate your first snapshot above — choose granularities and grouping dimensions, then slice the cached cube by period, payee and tag."
/>
</Paper>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<ReportList
reports={reports ?? []}
loading={loading}
fields={reportFields}
selectedId={selectedId}
onView={(id) => setSelectedId(id)}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
/>
</Box>
)}
</AccordionDetails>
</Accordion>
{selectedId && (
<ReportViewer
key={selectedId}
id={selectedId}
version={viewerVersion}
fields={txnFields}
onClose={() => setSelectedId(null)}
onRegenerated={handleRegenerate}
/>
)}
</Container>
);
}

View File

@@ -1,117 +0,0 @@
import React from "react";
import { Box, Paper, Typography, Button, IconButton, Skeleton, Tooltip } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import VisibilityIcon from "@mui/icons-material/Visibility";
import CachedIcon from "@mui/icons-material/Cached";
import { ListCellRenderer } from "../../react-openapi";
import type { ReportFieldConfigs } from "./types";
interface ReportListProps {
reports: any[];
loading: boolean;
fields: ReportFieldConfigs | null;
selectedId: string | null;
onView: (id: string) => void;
onRegenerate: (report: any) => void;
onDelete: (id: string) => void;
}
export function ReportList({ reports, loading, fields, selectedId, onView, onRegenerate, onDelete }: ReportListProps) {
if (loading && reports.length === 0) {
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{[0, 1, 2].map((i) => (
<Skeleton key={i} variant="rounded" height={88} sx={{ borderRadius: 2 }} />
))}
</Box>
);
}
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{reports.map((report) => {
const q = report.query ?? {};
const range = q.start_date || q.end_date ? `range ${q.start_date || "…"} → ${q.end_date || "…"}` : null;
const dims = Array.isArray(q.group_dims) ? q.group_dims.join(", ") : "";
const granularities = Array.isArray(q.granularities) ? q.granularities.join(", ") : "";
const accounts = Array.isArray(q.accounts) ? `${q.accounts.length} account${q.accounts.length === 1 ? "" : "s"}` : "all accounts";
const amounts =
q.min_amount != null || q.max_amount != null
? `amount ${q.min_amount ?? "0"} → ${q.max_amount ?? "∞"}`
: null;
return (
<Paper
key={report.id}
variant="outlined"
sx={{
p: 2,
borderRadius: 2,
borderColor: selectedId === report.id ? "primary.main" : "divider",
transition: "border-color 160ms ease",
"&:hover": { borderColor: "primary.light" },
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: "1 1 260px", minWidth: 0 }}>
<Typography variant="body1" fontWeight={600} noWrap sx={{ fontSize: "0.9375rem" }}>
{report.name || report.id}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", mt: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{granularities}
</Typography>
<Typography variant="caption" color="text.disabled">
/
</Typography>
<Typography variant="caption" color="text.secondary">
{dims}
</Typography>
{range && (
<Typography variant="caption" color="text.disabled">
{range}
</Typography>
)}
</Box>
<Box sx={{ display: "flex", gap: 1.5, mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{accounts}
</Typography>
{amounts && (
<Typography variant="caption" color="text.secondary">
{amounts}
</Typography>
)}
</Box>
</Box>
{fields && (
<Box sx={{ ml: "auto", flexShrink: 0, color: "text.secondary" }}>
<ListCellRenderer field={fields.generatedAt} value={report.generated_at} />
</Box>
)}
<Box sx={{ display: "flex", gap: 0.5, flexShrink: 0 }}>
<Button size="small" variant="contained" startIcon={<VisibilityIcon />} onClick={() => onView(report.id)}>
View
</Button>
<Button
size="small"
variant="outlined"
startIcon={<CachedIcon />}
onClick={() => onRegenerate(report)}
>
Regenerate
</Button>
<Tooltip title="Delete">
<IconButton aria-label="Delete report" onClick={() => onDelete(report.id)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
</Paper>
);
})}
</Box>
);
}

View File

@@ -1,502 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Box, Paper, Typography, Button, IconButton, Alert, Skeleton, Chip, MenuItem, Select, FormControl, InputLabel } from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import CachedIcon from "@mui/icons-material/Cached";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { FkMultiSelectField, useResource, formatCurrency, formatDateTime } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
import { StatCard } from "../common/components/StatCard";
import { TransactionList } from "../common/components/TransactionList";
import type { TxnFieldConfigs } from "../common/types";
import type { ListPeriodGroup } from "../common/utils/transactions";
import { buildDimensionBars, buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types";
import type { ReportDim } from "./types";
const MAX_VISIBLE_BARS = 10;
const periodField: FieldConfig = {
name: "period",
label: "Period",
description: "",
type: "string",
order: 0,
hidden: {},
filterable: true,
sortable: false,
readOnly: false,
required: false,
isArray: true,
};
const payeeField: FieldConfig = {
name: "payee",
label: "Payee",
description: "",
type: "string",
order: 0,
hidden: {},
filterable: true,
sortable: false,
readOnly: false,
required: false,
isArray: true,
};
const tagField: FieldConfig = {
name: "tag",
label: "Tag",
description: "",
type: "string",
order: 0,
hidden: {},
filterable: true,
sortable: false,
readOnly: false,
required: false,
isArray: true,
};
interface ReportViewerProps {
id: string;
version: number;
fields: TxnFieldConfigs | null;
onClose: () => void;
onRegenerated: (report: any) => void;
}
function snapshotGranularities(report: any): string[] {
const fromQuery = report?.query?.granularities;
if (Array.isArray(fromQuery) && fromQuery.length) return fromQuery;
const fromResponse = report?.granularities;
if (Array.isArray(fromResponse) && fromResponse.length) return fromResponse;
const series = report?.buckets?.[0]?.series;
return series ? Object.keys(series) : [];
}
export function ReportViewer({ id, version, fields, onClose, onRegenerated }: ReportViewerProps) {
const { get } = useResource("reports");
const [report, setReport] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
const [granularity, setGranularity] = useState<string | null>(null);
const [flow, setFlow] = useState("outflows");
const [dim, setDim] = useState<ReportDim>("period");
const [selectedPeriods, setSelectedPeriods] = useState<string[]>([]);
const [selectedPayees, setSelectedPayees] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const prevGranularity = useRef<string | null>(null);
const params = useMemo(() => {
const p: Record<string, any> = { flow };
if (granularity) p.granularity = [granularity];
if (selectedPeriods.length) p.period_ids = selectedPeriods;
// The active breakdown dim requests every cube value ("*") when its own
// filter is unselected, so bars show the full distribution.
if (selectedPayees.length) p.payee = selectedPayees;
else if (dim === "payee") p.payee = ["*"];
if (selectedTags.length) p.tags = selectedTags;
else if (dim === "tag") p.tags = ["*"];
return p;
}, [granularity, flow, dim, selectedPeriods, selectedPayees, selectedTags]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
const previous = prevGranularity.current;
get(id, params)
.then((res) => {
if (!mounted) return;
setReport(res);
const options = snapshotGranularities(res);
if (granularity === null && options.length) setGranularity(options[0]);
if (previous !== null && previous !== granularity) setSelectedPeriods([]);
prevGranularity.current = granularity;
})
.catch((e: any) => {
if (!mounted) return;
setError(apiErrorMessage(e));
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, [id, version, reload, params, get, granularity]);
const granularityOptions = useMemo(() => snapshotGranularities(report), [report]);
const periodOptions = useMemo(
() => (Array.isArray(report?.period_ids) ? report.period_ids.map((label: string) => ({ value: label, label })) : []),
[report],
);
const payeeOptions = useMemo(
() => (Array.isArray(report?.payees) ? report.payees.map((label: string) => ({ value: label, label })) : []),
[report],
);
const tagOptions = useMemo(
() => (Array.isArray(report?.tags) ? report.tags.map((label: string) => ({ value: label, label })) : []),
[report],
);
const filter = useMemo(
() => ({
granularity: granularity ?? granularityOptions[0] ?? "",
periods: selectedPeriods,
payees: selectedPayees,
tags: selectedTags,
}),
[granularity, granularityOptions, selectedPeriods, selectedPayees, selectedTags],
);
const periodGroups = useMemo(() => buildPeriodGroups(report?.buckets ?? [], filter), [report, filter]);
const slice = useMemo(() => sliceSummary(periodGroups), [periodGroups]);
const bars = useMemo(() => buildDimensionBars(report?.buckets ?? [], filter, dim), [report, filter, dim]);
const visibleBars = useMemo(() => bars.slice(0, MAX_VISIBLE_BARS), [bars]);
const listGroups = useMemo<ListPeriodGroup[]>(
() =>
periodGroups.map((g) => ({
key: g.key,
label: g.key,
items: g.txns,
spent: g.metrics.outflows,
income: g.metrics.inflows,
currency: g.currency,
metrics: {
sum: g.metrics.sum,
count: g.metrics.count,
avg: g.metrics.avg,
min: g.metrics.min,
max: g.metrics.max,
cadence: g.metrics.cadence,
frequency: g.metrics.frequency,
},
})),
[periodGroups],
);
const barsListRef = useRef<HTMLDivElement>(null);
const [barScroll, setBarScroll] = useState({ canUp: false, canDown: false, overflow: false });
const updateBarScroll = useCallback(() => {
const el = barsListRef.current;
if (!el) return;
const overflow = el.scrollHeight > el.clientHeight + 2;
setBarScroll({
overflow,
canUp: overflow && el.scrollTop > 2,
canDown: overflow && el.scrollTop < el.scrollHeight - el.clientHeight - 2,
});
}, []);
useEffect(() => {
const el = barsListRef.current;
if (el) el.scrollTop = 0;
updateBarScroll();
}, [dim, visibleBars.length, updateBarScroll]);
const scrollBarsByPage = useCallback((dir: number) => {
const el = barsListRef.current;
if (!el) return;
el.scrollTo({ top: dir > 0 ? el.scrollHeight : 0, behavior: "smooth" });
}, []);
if (loading && !report) {
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
<Skeleton variant="text" width={220} height={28} />
<Skeleton variant="rounded" height={120} sx={{ my: 2, borderRadius: 2 }} />
<Skeleton variant="rounded" height={240} sx={{ borderRadius: 2 }} />
</Paper>
);
}
if (error) {
return (
<Paper variant="outlined" sx={{ p: 3, borderRadius: 3 }}>
<Alert severity="error" sx={{ borderRadius: 2 }}>
{error}
</Alert>
<Button size="small" sx={{ mt: 2 }} onClick={() => setReload((r) => r + 1)}>
Retry
</Button>
</Paper>
);
}
if (!report) return null;
const activeGranularity = granularity ?? report.granularities?.[0] ?? "";
const maxBar = bars.reduce((m, b) => Math.max(m, Math.abs(b.sum)), 0);
const dimOptions: { id: ReportDim; label: string; count: number }[] = [
{ id: "period", label: "Period", count: report.period_ids?.length ?? 0 },
{ id: "payee", label: "Payees", count: report.payees?.length ?? 0 },
{ id: "tag", label: "Tags", count: report.tags?.length ?? 0 },
];
const activeDimIndex = Math.max(
0,
dimOptions.findIndex((d) => d.id === dim),
);
const range =
report.query?.start_date || report.query?.end_date
? `range ${report.query.start_date || "…"} → ${report.query.end_date || "…"}`
: null;
return (
<Paper variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
px: 2.5,
py: 2,
borderBottom: "1px solid",
borderColor: "divider",
flexWrap: "wrap",
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{report.name}
</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
<Typography variant="caption" color="text.secondary">
flow {report.flow} · generated {formatDateTime(report.generated_at ?? report.created_at)}
</Typography>
{range && (
<Typography variant="caption" color="text.disabled">
{range}
</Typography>
)}
<Typography variant="caption" color="text.disabled">
{report.payees?.length ?? 0} payees · {report.tags?.length ?? 0} tags
</Typography>
</Box>
</Box>
<IconButton aria-label="Regenerate report" onClick={() => onRegenerated(report)}>
<CachedIcon />
</IconButton>
<IconButton aria-label="Close report" onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", alignItems: "center", mb: 2 }}>
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{granularityOptions.map((g: string) => (
<Chip
key={g}
label={g}
clickable
size="small"
color={activeGranularity === g ? "primary" : "default"}
variant={activeGranularity === g ? "filled" : "outlined"}
onClick={() => setGranularity(g)}
/>
))}
</Box>
<FormControl size="small" sx={{ width: 140 }}>
<InputLabel id="viewer-flow-label">Flow</InputLabel>
<Select labelId="viewer-flow-label" label="Flow" value={flow} onChange={(e) => setFlow(e.target.value)}>
{FLOW_OPTIONS.map((f) => (
<MenuItem key={f} value={f}>
{f}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 2.5 }}>
<Box sx={{ width: 260 }}>
<FkMultiSelectField
field={{ ...periodField, readOnly: periodOptions.length === 0 }}
fkOptions={periodOptions}
value={selectedPeriods}
onChange={setSelectedPeriods}
/>
</Box>
<Box sx={{ width: 260 }}>
<FkMultiSelectField
field={{ ...payeeField, readOnly: payeeOptions.length === 0 }}
fkOptions={payeeOptions}
value={selectedPayees}
onChange={setSelectedPayees}
/>
</Box>
<Box sx={{ width: 260 }}>
<FkMultiSelectField
field={{ ...tagField, readOnly: tagOptions.length === 0 }}
fkOptions={tagOptions}
value={selectedTags}
onChange={setSelectedTags}
/>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: "center" }}>
{slice.txns.length} transactions · {slice.count} rows
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 2.5 }}>
<StatCard label="Outflows" value={formatCurrency(slice.outflows, slice.currency)} color="error.main" />
<StatCard label="Inflows" value={formatCurrency(slice.inflows, slice.currency)} color="success.main" />
<StatCard label="Net" value={formatCurrency(slice.inflows - slice.outflows, slice.currency)} color="info.main" />
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
</Box>
<Box sx={{ mb: 2.5 }}>
<Box
sx={{
position: "relative",
display: "flex",
alignItems: "stretch",
width: "fit-content",
maxWidth: "100%",
border: "1px solid",
borderColor: "divider",
borderRadius: "999px",
p: 0.5,
backgroundColor: "background.default",
}}
>
<Box
aria-hidden
sx={{
position: "absolute",
top: 4,
bottom: 4,
left: 4,
width: `calc((100% - 8px) / ${dimOptions.length})`,
borderRadius: "999px",
backgroundColor: "primary.main",
transform: `translateX(${activeDimIndex * 100}%)`,
transition: "transform 250ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
{dimOptions.map((d) => {
const active = d.id === dim;
return (
<Box
key={d.id}
component="button"
type="button"
disabled={d.count <= 1}
onClick={() => setDim(d.id)}
sx={{
position: "relative",
zIndex: 1,
flex: 1,
border: "none",
background: "transparent",
px: 3,
py: 1,
borderRadius: "999px",
cursor: d.count <= 1 ? "default" : "pointer",
typography: "body2",
fontWeight: 700,
letterSpacing: "-0.01em",
color:
d.count <= 1
? "text.disabled"
: active
? "primary.contrastText"
: "text.secondary",
whiteSpace: "nowrap",
transition: "color 160ms ease",
"&:focus-visible": { outline: "2px solid", outlineColor: "primary.main" },
}}
>
{d.label}
</Box>
);
})}
</Box>
{visibleBars.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mt: 1.5 }}>
<Typography variant="body2" color="text.secondary">
No data for this slice. Try another granularity, period or payer.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ borderRadius: 2, mt: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "stretch" }}>
<Box
ref={barsListRef}
onScroll={updateBarScroll}
sx={{
flex: 1,
minWidth: 0,
maxHeight: 220,
overflowY: "auto",
py: 1,
px: 2,
scrollbarWidth: "none",
"&::-webkit-scrollbar": { display: "none" },
}}
>
{visibleBars.map((b) => (
<Box key={b.key} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
{b.key}
</Typography>
<Box
sx={{
height: 20,
borderRadius: 1,
bgcolor: flow === "outflows" ? "error.main" : "success.main",
opacity: 0.85,
minWidth: 4,
flexGrow: 0,
}}
style={{
width: `${maxBar ? Math.max((Math.abs(b.sum) / maxBar) * 100, 2) : 2}%`,
}}
/>
<Typography variant="body2" fontWeight={600}>
{formatCurrency(b.sum, slice.currency)}
</Typography>
<Typography variant="caption" color="text.disabled">
{b.count} txn{b.count === 1 ? "" : "s"}
</Typography>
</Box>
))}
</Box>
{barScroll.overflow && (
<Box sx={{ display: "flex", flexDirection: "column", justifyContent: "center", gap: 0.5, pr: 1.5 }}>
<IconButton
size="small"
aria-label="Scroll bars up"
disabled={!barScroll.canUp}
onClick={() => scrollBarsByPage(-1)}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Scroll bars down"
disabled={!barScroll.canDown}
onClick={() => scrollBarsByPage(1)}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
</Paper>
)}
</Box>
{slice.txns.length === 0 ? null : fields ? (
<TransactionList fields={fields} groups={listGroups} showMetrics />
) : null}
</Box>
</Paper>
);
}

View File

@@ -1,288 +0,0 @@
import type { FieldConfig, ResourceConfig } from "../../react-openapi";
import { parseOccurredAt } from "../common/utils/dates";
export interface PeriodMetricsVM {
outflows: number;
inflows: number;
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
firstDate: string | null;
lastDate: string | null;
cadence: number | null;
frequency: number | null;
}
export interface ReportPeriodGroup {
key: string;
metrics: PeriodMetricsVM;
txns: any[];
currency: string;
}
export interface SliceSummary {
outflows: number;
inflows: number;
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
firstDate: string | null;
lastDate: string | null;
txns: any[];
currency: string;
}
export interface SliceFilter {
granularity: string;
periods?: string[];
payees?: string[];
tags?: string[];
}
export interface ReportFieldConfigs {
name: FieldConfig;
generatedAt: FieldConfig;
}
export interface MetricLabel {
key: string;
label: string;
order: number;
}
export function apiErrorMessage(e: any): string {
if (e?.response?.data) {
const d = e.response.data;
if (Array.isArray(d)) return d.map((x: any) => x?.msg ?? String(x)).join("; ");
if (typeof d.detail === "string") return d.detail;
if (typeof d.detail?.msg === "string") return d.detail.msg;
if (typeof d === "string") return d;
}
return e?.message ?? "Request failed";
}
export const FLOW_OPTIONS = ["both", "inflows", "outflows"];
export function metricLabels(schemas: Record<string, any>): MetricLabel[] {
const props: Record<string, any> = schemas?.ReportMetrics?.properties ?? {};
return Object.entries(props)
.filter(([, p]) => p && typeof p === "object")
.map(([key, p]) => ({
key,
label: (p as any)["x-label"] ?? key,
order: (p as any)["x-order"] ?? Infinity,
}))
.sort((a, b) => a.order - b.order);
}
function dateVal(value: string): number {
try {
return parseOccurredAt(value).getTime();
} catch {
return 0;
}
}
function bucketMatches(bucket: any, filter: SliceFilter): boolean {
const gk = bucket?.group_key ?? {};
if (filter.payees?.length && !(gk.payee ?? []).some((p: string) => filter.payees?.includes(p))) return false;
if (filter.tags?.length && !(gk.tag ?? []).some((t: string) => filter.tags?.includes(t))) return false;
return true;
}
function num(v: any): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
/**
* Groups cube periods by their canonical period_id, merging buckets per
* period. The server returns disjoint slices, so additive metric merge is
* safe; txn ids are deduped defensively. Metrics come verbatim from the API
* except when multiple buckets contribute to one period — then cadence and
* frequency are re-derived from the merged txn dates.
*/
export function buildPeriodGroups(buckets: any[], filter: SliceFilter): ReportPeriodGroup[] {
interface Acc {
metrics: PeriodMetricsVM;
txns: any[];
sources: number;
apiCadence: number | null;
apiFrequency: number | null;
}
const byPeriod = new Map<string, Acc>();
const seenTxnIds = new Set<string>();
let currency = "INR";
for (const bucket of buckets ?? []) {
if (!bucketMatches(bucket, filter)) continue;
for (const period of bucket.series?.[filter.granularity] ?? []) {
if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue;
const m = period.metrics ?? {};
let acc = byPeriod.get(period.period_id);
if (!acc) {
acc = {
metrics: {
outflows: 0,
inflows: 0,
sum: 0,
count: 0,
avg: null,
min: null,
max: null,
firstDate: null,
lastDate: null,
cadence: null,
frequency: null,
},
txns: [],
sources: 0,
apiCadence: num(m.cadence),
apiFrequency: num(m.frequency),
};
byPeriod.set(period.period_id, acc);
}
acc.sources += 1;
const vm = acc.metrics;
vm.outflows += m.outflows ?? 0;
vm.inflows += m.inflows ?? 0;
vm.sum += m.sum ?? 0;
vm.count += m.count ?? 0;
const mn = num(m.min);
if (mn != null) vm.min = vm.min == null ? mn : Math.min(vm.min, mn);
const mx = num(m.max);
if (mx != null) vm.max = vm.max == null ? mx : Math.max(vm.max, mx);
if (m.first_date && (!vm.firstDate || dateVal(String(m.first_date)) < dateVal(vm.firstDate))) {
vm.firstDate = String(m.first_date);
}
if (m.last_date && (!vm.lastDate || dateVal(String(m.last_date)) > dateVal(vm.lastDate))) {
vm.lastDate = String(m.last_date);
}
for (const txn of period.txns ?? []) {
if (txn?.id != null) {
if (seenTxnIds.has(txn.id)) continue;
seenTxnIds.add(txn.id);
}
acc.txns.push(txn);
const c = txn?.account?.currency;
if (c) currency = c;
}
}
}
return [...byPeriod.entries()]
.map(([key, acc]) => {
const vm = acc.metrics;
vm.avg = vm.count ? Math.round((vm.sum / vm.count) * 100) / 100 : null;
if (acc.sources > 1) {
const dates = acc.txns
.map((t) => {
try {
return parseOccurredAt(t?.occurred_at).getTime();
} catch {
return NaN;
}
})
.filter((t) => !Number.isNaN(t))
.sort((a, b) => a - b);
if (dates.length >= 2) {
let gapSum = 0;
for (let i = 0; i < dates.length - 1; i += 1) gapSum += (dates[i + 1] - dates[i]) / 86400000;
const cadence = Math.round((gapSum / (dates.length - 1)) * 100) / 100;
vm.cadence = cadence > 0 ? cadence : null;
vm.frequency = cadence > 0 ? Math.round((1 / cadence) * 100) / 100 : null;
} else {
vm.cadence = null;
vm.frequency = null;
}
} else {
vm.cadence = acc.apiCadence;
vm.frequency = acc.apiFrequency;
}
return { key, metrics: vm, txns: acc.txns, currency };
})
.sort((a, b) => b.key.localeCompare(a.key));
}
export function sliceSummary(groups: ReportPeriodGroup[]): SliceSummary {
const outflows = groups.reduce((s, g) => s + g.metrics.outflows, 0);
const inflows = groups.reduce((s, g) => s + g.metrics.inflows, 0);
const sum = groups.reduce((s, g) => s + g.metrics.sum, 0);
const count = groups.reduce((s, g) => s + g.metrics.count, 0);
let min: number | null = null;
let max: number | null = null;
let firstDate: string | null = null;
let lastDate: string | null = null;
let currency = "INR";
const txns: any[] = [];
for (const g of groups) {
if (g.metrics.min != null) min = min == null ? g.metrics.min : Math.min(min, g.metrics.min);
if (g.metrics.max != null) max = max == null ? g.metrics.max : Math.max(max, g.metrics.max);
if (g.metrics.firstDate && (!firstDate || dateVal(g.metrics.firstDate) < dateVal(firstDate))) {
firstDate = g.metrics.firstDate;
}
if (g.metrics.lastDate && (!lastDate || dateVal(g.metrics.lastDate) > dateVal(lastDate))) {
lastDate = g.metrics.lastDate;
}
if (g.currency) currency = g.currency;
txns.push(...g.txns);
}
return { outflows, inflows, sum, count, avg: count ? sum / count : null, min, max, firstDate, lastDate, txns, currency };
}
export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null {
const reportsRes = resources.find((r) => r.name === "reports");
if (!reportsRes) return null;
const find = (name: string) => reportsRes.fields.find((f) => f.name === name);
const name = find("name");
const generatedAt = find("generated_at");
if (!name || !generatedAt) return null;
return { name, generatedAt };
}
export type ReportDim = "period" | "payee" | "tag";
export interface DimensionBar {
key: string;
sum: number;
count: number;
}
/**
* Bar-chart rows for a given dimension. "period" reuses the period groups;
* "payee"/"tag" fold each bucket's metrics into every value listed under
* group_key[dim] — safe because server buckets are disjoint slices.
*/
export function buildDimensionBars(buckets: any[], filter: SliceFilter, dim: ReportDim): DimensionBar[] {
if (dim === "period") {
return buildPeriodGroups(buckets, filter).map((g) => ({
key: g.key,
sum: g.metrics.sum,
count: g.metrics.count,
}));
}
const acc = new Map<string, DimensionBar>();
for (const bucket of buckets ?? []) {
if (!bucketMatches(bucket, filter)) continue;
const keys: string[] = bucket.group_key?.[dim] ?? [];
for (const period of bucket.series?.[filter.granularity] ?? []) {
if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue;
const m = period.metrics ?? {};
const sum = typeof m.sum === "number" ? m.sum : 0;
const count = typeof m.count === "number" ? m.count : 0;
for (const k of keys) {
const cur = acc.get(k);
if (cur) {
cur.sum += sum;
cur.count += count;
} else {
acc.set(k, { key: k, sum, count });
}
}
}
}
return [...acc.values()].sort((a, b) => Math.abs(b.sum) - Math.abs(a.sum));
}

26
src/RequireAuth.tsx Normal file
View File

@@ -0,0 +1,26 @@
import * as React from "react";
import { useNavigate } from "react-router-dom";
import { useAuth, AuthPage } from "../react-auth";
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { currentUser, loading, error, login, register } = useAuth();
const navigate = useNavigate();
const [mode, setMode] = React.useState<"login" | "register">("login");
if (currentUser) {
return <>{children}</>;
}
return (
<AuthPage
mode={mode}
onBack={() => navigate("/")}
onSwitchMode={() => setMode(mode === "login" ? "register" : "login")}
login={login}
register={register}
loading={loading}
error={error}
currentUser={currentUser}
/>
);
}

View File

@@ -1,26 +0,0 @@
import { Paper, Typography } from "@mui/material";
interface StatCardProps {
label: string;
value: string;
color?: string;
hint?: string;
}
export function StatCard({ label, value, color, hint }: StatCardProps) {
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3, flex: 1, minWidth: 180 }}>
<Typography variant="caption" color="text.secondary" sx={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
{label}
</Typography>
<Typography variant="h5" fontWeight={700} sx={{ mt: 0.5, letterSpacing: "-0.02em", color }}>
{value}
</Typography>
{hint && (
<Typography variant="caption" color="text.secondary">
{hint}
</Typography>
)}
</Paper>
);
}

View File

@@ -1,356 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
Menu,
MenuItem,
ListItemText,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { formatCurrency } from "../../../react-openapi";
import type { ExpenseItem, TxnFieldConfigs } from "../types";
import { computeTxnMetrics, groupByDate, groupByPeriod } from "../utils/transactions";
import type { ListGroupMetrics, ListPeriodGroup, PeriodGranularity } from "../utils/transactions";
import { TransactionRow } from "./TransactionRow";
import { StatCard } from "./StatCard";
interface TransactionListProps {
items?: ExpenseItem[];
fields: TxnFieldConfigs;
granularity?: PeriodGranularity;
showMetrics?: boolean;
groups?: ListPeriodGroup[];
}
function cadenceRow(cadenceDays: number | null, frequency: number | null): { label: string; value: string } {
const fmt = (v: number) => (Number.isInteger(v) ? String(v) : v.toFixed(2));
if (cadenceDays != null && cadenceDays < 1) {
return { label: "Frequency", value: `${frequency == null ? "—" : fmt(frequency)} /day` };
}
return {
label: "Cadence",
value: cadenceDays == null ? "—" : `${fmt(cadenceDays)} days`,
};
}
function txnFallbackMetrics(items: ExpenseItem[]): ListGroupMetrics {
const t = computeTxnMetrics(items);
return {
sum: t.sum,
count: t.count,
avg: t.avg,
min: t.min,
max: t.max,
cadence: t.cadenceDays,
frequency: t.frequency,
};
}
function GroupMetrics({
items,
currency,
metrics,
}: {
items: ExpenseItem[];
currency: string;
metrics?: ListGroupMetrics;
}) {
const m = metrics ?? txnFallbackMetrics(items);
const rows: { label: string; value: string }[] = [
{ label: "Sum", value: formatCurrency(m.sum, currency) },
{ label: "Avg", value: m.avg == null ? "—" : formatCurrency(m.avg, currency) },
{ label: "Min", value: m.min == null ? "—" : formatCurrency(m.min, currency) },
{ label: "Max", value: m.max == null ? "—" : formatCurrency(m.max, currency) },
cadenceRow(m.cadence, m.frequency),
];
return (
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", width: "100%" }}>
{rows.map((row) => (
<StatCard key={row.label} label={row.label} value={row.value} />
))}
</Box>
);
}
export function TransactionList({
items,
fields,
granularity = "monthly",
showMetrics = false,
groups: externalGroups,
}: TransactionListProps) {
const [activeMonth, setActiveMonth] = useState<string | null>(null);
const [openMonth, setOpenMonth] = useState<string | null>(null);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
const groups = useMemo<ListPeriodGroup[]>(
() => externalGroups ?? groupByPeriod(items ?? [], granularity),
[externalGroups, items, granularity],
);
const listRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const didInitOpenMonth = useRef(false);
useEffect(() => {
if (!didInitOpenMonth.current && groups.length > 0) {
didInitOpenMonth.current = true;
setOpenMonth(groups[0].key);
}
}, [groups]);
const scrollToMonth = useCallback((key: string) => {
const anchor = listRef.current?.querySelector<HTMLElement>(
`[data-month-anchor="${key}"]`,
);
if (!anchor) return;
const offset = window.matchMedia("(min-width: 900px)").matches ? 64 : 56;
const top = anchor.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: "smooth" });
}, []);
const handleSelectMonth = useCallback(
(key: string) => {
setMenuAnchor(null);
setOpenMonth(key);
scrollToMonth(key);
},
[scrollToMonth],
);
const handlePillKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (pillRef.current) setMenuAnchor(pillRef.current);
}
},
[],
);
const selectedMonth = activeMonth ?? openMonth ?? groups[0]?.key ?? null;
useEffect(() => {
const root = listRef.current;
if (!root || groups.length === 0) return;
let ticking = false;
const update = () => {
ticking = false;
const headers = root.querySelectorAll<HTMLElement>("[data-month-header]");
let current: string | null = null;
for (const header of headers) {
if (header.getBoundingClientRect().top <= 72) {
current = header.dataset.monthHeader ?? null;
} else {
break;
}
}
setActiveMonth(current);
};
const onScroll = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
};
update();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [groups]);
return (
<>
<Box ref={listRef} sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
{groups.map((group) => (
<Box key={group.key}>
<span data-month-anchor={group.key} aria-hidden="true" style={{ display: "block", height: 0 }} />
<Accordion
disableGutters
data-month-header={group.key}
expanded={openMonth === group.key}
onChange={(_, isExpanded) => setOpenMonth(isExpanded ? group.key : null)}
TransitionProps={{ unmountOnExit: true }}
sx={{
border: "1px solid",
borderColor: openMonth === group.key ? "primary.main" : "divider",
borderRadius: 2,
overflow: "hidden",
boxShadow: "none",
backgroundColor: "background.paper",
"&:before": { display: "none" },
transition: "border-color 160ms ease, background-color 160ms ease",
"&:hover": { borderColor: "primary.light" },
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
px: 2,
py: 1,
"& .MuiAccordionSummary-content": {
flexDirection: "column",
alignItems: "stretch",
gap: 0.75,
minWidth: 0,
},
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, minWidth: 0 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{group.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="body2" fontWeight={700} color="error.main">
{formatCurrency(group.spent, group.currency)}
</Typography>
<Typography variant="body2" color="text.disabled">
/
</Typography>
<Typography variant="body2" fontWeight={700} color="success.main">
{formatCurrency(group.income, group.currency)}
</Typography>
</Box>
{showMetrics && (
<GroupMetrics items={group.items} currency={group.currency} metrics={group.metrics} />
)}
</AccordionSummary>
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{groupByDate(group.items).map((dateGroup) => (
<Box
key={dateGroup.date}
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
backgroundColor: "background.default",
p: 1,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 1,
pb: 1,
borderBottom: "1px solid",
borderColor: "divider",
mb: 1,
}}
>
<Typography variant="subtitle2" fontWeight={600} sx={{ letterSpacing: "-0.01em" }}>
{dateGroup.label}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="caption" color="text.secondary">
{dateGroup.items.length} transaction{dateGroup.items.length === 1 ? "" : "s"}
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{dateGroup.items.map((item) => (
<TransactionRow
key={item.id}
item={item}
currency={group.currency}
fields={fields}
/>
))}
</Box>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
</Box>
))}
</Box>
<Box
sx={{
position: "fixed",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: 3,
pointerEvents: activeMonth ? "auto" : "none",
opacity: activeMonth ? 1 : 0,
transition: "opacity 160ms ease",
}}
>
<Box
ref={pillRef}
role="button"
tabIndex={0}
onClick={(e) => setMenuAnchor(e.currentTarget)}
onKeyDown={handlePillKeyDown}
aria-haspopup="menu"
aria-expanded={Boolean(menuAnchor)}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1.5,
py: 0.75,
borderRadius: "999px",
cursor: "pointer",
userSelect: "none",
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85),
backdropFilter: "blur(8px)",
border: "1px solid",
borderColor: "divider",
boxShadow: 1,
transition: "background-color 160ms ease, transform 160ms ease",
"&:hover": {
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.95),
transform: "translateY(-1px)",
},
"&:focus-visible": {
outline: "2px solid",
outlineColor: "primary.main",
},
}}
>
<Typography variant="caption" fontWeight={700} color="text.secondary">
{activeMonth ? (groups.find((g) => g.key === activeMonth)?.label ?? activeMonth) : ""}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: "text.secondary" }} />
</Box>
</Box>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={() => setMenuAnchor(null)}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
transformOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{groups.map((group) => (
<MenuItem
key={group.key}
selected={selectedMonth === group.key}
onClick={() => handleSelectMonth(group.key)}
>
<ListItemText
primary={group.label}
secondary={`${group.items.length} transactions`}
/>
</MenuItem>
))}
</Menu>
</>
);
}

View File

@@ -1,71 +0,0 @@
import React from "react";
import { Box, Typography } from "@mui/material";
import { ListCellRenderer, CurrencyField, applyDisplayFormat } from "../../../react-openapi";
import type { ExpenseItem, TxnFieldConfigs } from "../types";
interface TransactionRowProps {
item: ExpenseItem;
currency: string;
fields: TxnFieldConfigs;
}
export const TransactionRow = React.memo(function TransactionRow({ item, currency, fields }: TransactionRowProps) {
const itemCurrency = item.account?.currency ?? currency;
return (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
px: 2,
py: 1.25,
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
backgroundColor: "background.paper",
transition: "background-color 160ms ease, border-color 160ms ease",
"&:hover": {
backgroundColor: "action.hover",
borderColor: "primary.light",
},
}}
>
<Box
sx={{
width: 40,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{item.entity?.logo ? (
<ListCellRenderer field={fields.logo} value={item.entity.logo} />
) : (
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: "action.hover" }} />
)}
</Box>
<Box sx={{ flex: "0 1 auto", minWidth: 0 }}>
<Typography
variant="body1"
fontWeight={600}
noWrap
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
>
{item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ color: "text.secondary" }}>
<ListCellRenderer field={fields.occurredAt} value={item.occurred_at} />
</Box>
{item.account?.name && (
<ListCellRenderer field={fields.account} value={item.account} displayFormat={fields.formats.account} />
)}
</Box>
</Box>
<Box sx={{ flex: 1 }} />
<CurrencyField value={item.amount} currency={itemCurrency} large />
</Box>
);
});

View File

@@ -1,21 +0,0 @@
import type { FieldConfig } from "../../react-openapi";
export interface ExpenseItem {
id: string;
entity?: { name?: string; type?: string; logo?: string } | null;
amount: number;
account?: { name?: string; number?: string; type?: string; currency?: string } | null;
tags?: { icon?: string; name?: string }[];
occurred_at?: string;
created_at?: string;
updated_at?: string;
}
export interface TxnFieldConfigs {
entity: FieldConfig;
amount: FieldConfig;
account: FieldConfig;
occurredAt: FieldConfig;
logo: FieldConfig;
formats: { entity: string; account: string };
}

View File

@@ -1,71 +0,0 @@
const DDMMYYYY = /^(\d{2})-(\d{2})-(\d{4})$/;
export function parseOccurredAt(value?: string): Date {
const m = value?.match(DDMMYYYY);
if (!m) {
throw new Error(`Expense occurred_at is not DD-MM-YYYY: ${value}`);
}
const [, dd, mm, yyyy] = m;
const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
if (
Number.isNaN(d.getTime()) ||
d.getDate() !== Number(dd) ||
d.getMonth() !== Number(mm) - 1 ||
d.getFullYear() !== Number(yyyy)
) {
throw new Error(`Invalid expense occurred_at date: ${value}`);
}
return d;
}
export function isDdmmyyyy(value?: string): boolean {
if (!value) return true;
const m = value.match(DDMMYYYY);
if (!m) return false;
const [, dd, mm, yyyy] = m;
const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
return !(
Number.isNaN(d.getTime()) ||
d.getDate() !== Number(dd) ||
d.getMonth() !== Number(mm) - 1 ||
d.getFullYear() !== Number(yyyy)
);
}
export function parseDdmmyyyy(value: string): Date {
const m = value.match(DDMMYYYY);
if (!m) throw new Error(`Date is not DD-MM-YYYY: ${value}`);
const [, dd, mm, yyyy] = m;
const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
if (
Number.isNaN(d.getTime()) ||
d.getDate() !== Number(dd) ||
d.getMonth() !== Number(mm) - 1 ||
d.getFullYear() !== Number(yyyy)
) {
throw new Error(`Invalid date: ${value}`);
}
return d;
}
export function monthKey(value?: string): string {
const d = parseOccurredAt(value);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
export function currentMonthKey(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
export function monthLabel(key: string): string {
const [y, m] = key.split("-").map(Number);
if (!y || !m) return key;
const d = new Date(y, m - 1, 1);
return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" });
}
export function dateLabel(value: string): string {
const d = parseOccurredAt(value);
return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
}

View File

@@ -1,26 +0,0 @@
import type { ResourceConfig } from "../../../react-openapi";
import type { TxnFieldConfigs } from "../types";
export function buildTxnFieldConfigs(resources: ResourceConfig[]): TxnFieldConfigs | null {
const expensesRes = resources.find((r) => r.name === "expenses");
const entitiesRes = resources.find((r) => r.name === "entities");
const accountsRes = resources.find((r) => r.name === "accounts");
const find = (res: ResourceConfig | undefined, name: string) => res?.fields.find((f) => f.name === name);
const entity = find(expensesRes, "entity");
const amount = find(expensesRes, "amount");
const account = find(expensesRes, "account");
const occurredAt = find(expensesRes, "occurred_at");
const logo = find(entitiesRes, "logo");
if (!entity || !amount || !account || !occurredAt || !logo) return null;
return {
entity,
amount,
account,
occurredAt,
logo,
formats: {
entity: entitiesRes?.displayFormat ?? "{name}",
account: accountsRes?.displayFormat ?? "{name}",
},
};
}

View File

@@ -1,161 +0,0 @@
import type { ExpenseItem } from "../types";
import { dateLabel, monthKey, monthLabel, parseOccurredAt } from "./dates";
export function isExpense(item: ExpenseItem): boolean {
return (item.amount ?? 0) < 0;
}
export interface DateGroup {
date: string;
label: string;
items: ExpenseItem[];
}
export function groupByDate(items: ExpenseItem[]): DateGroup[] {
const sorted = [...items].sort(
(a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
);
const map = new Map<string, ExpenseItem[]>();
for (const item of sorted) {
const key = item.occurred_at ?? "";
const list = map.get(key) ?? [];
list.push(item);
map.set(key, list);
}
return [...map.entries()].map(([date, list]) => ({
date,
label: dateLabel(date),
items: list,
}));
}
export type PeriodGranularity = "weekly" | "monthly" | "quarterly" | "yearly";
export interface PeriodGroup {
key: string;
label: string;
items: ExpenseItem[];
spent: number;
income: number;
currency: string;
}
/** Metrics verbatim from the API's ReportMetrics (camelCased). */
export interface ListGroupMetrics {
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
cadence: number | null;
frequency: number | null;
}
export interface ListPeriodGroup extends PeriodGroup {
metrics?: ListGroupMetrics;
}
function isoWeekKey(d: Date): string {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const day = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
const week = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}
function quarterKey(d: Date): string {
return `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`;
}
export function periodKey(value: string | undefined, granularity: PeriodGranularity): string {
const d = parseOccurredAt(value);
switch (granularity) {
case "weekly":
return isoWeekKey(d);
case "quarterly":
return quarterKey(d);
case "yearly":
return String(d.getFullYear());
default:
return monthKey(value);
}
}
export function periodLabel(key: string, granularity: PeriodGranularity): string {
switch (granularity) {
case "weekly":
case "yearly":
return key;
case "quarterly": {
const [y, q] = key.split("-Q");
return `Q${q} ${y}`;
}
default:
return monthLabel(key);
}
}
export function groupByPeriod(items: ExpenseItem[], granularity: PeriodGranularity = "monthly"): PeriodGroup[] {
const map = new Map<string, ExpenseItem[]>();
for (const item of items) {
const key = periodKey(item.occurred_at, granularity);
const list = map.get(key) ?? [];
list.push(item);
map.set(key, list);
}
return [...map.entries()]
.map(([key, list]) => {
const sorted = [...list].sort(
(a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
);
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0);
const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0);
return { key, label: periodLabel(key, granularity), items: sorted, spent, income, currency };
})
.sort((a, b) => b.key.localeCompare(a.key));
}
export interface TxnMetrics {
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
cadenceDays: number | null;
frequency: number | null;
}
/** Mirrors backend ReportMetrics.compute_metrics over a list of transactions. */
export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics {
const amounts = items.filter((it) => typeof it.amount === "number").map((it) => it.amount as number);
const empty = { sum: 0, count: 0, avg: null, min: null, max: null, cadenceDays: null, frequency: null };
if (amounts.length === 0) {
return empty;
}
const dates = items
.map((it) => it.occurred_at)
.filter((d): d is string => !!d)
.map((d) => parseOccurredAt(d).getTime())
.sort((a, b) => a - b);
const sum = amounts.reduce((s, a) => s + a, 0);
let cadenceDays: number | null = null;
if (dates.length >= 2) {
const gaps: number[] = [];
for (let i = 0; i < dates.length - 1; i += 1) {
gaps.push((dates[i + 1] - dates[i]) / 86400000);
}
cadenceDays = Math.round((gaps.reduce((s, g) => s + g, 0) / gaps.length) * 100) / 100;
}
const frequency = cadenceDays != null && cadenceDays > 0 ? Math.round((1 / cadenceDays) * 100) / 100 : null;
return {
sum,
count: amounts.length,
avg: sum / amounts.length,
min: Math.min(...amounts),
max: Math.max(...amounts),
cadenceDays,
frequency,
};
}

View File

@@ -7,12 +7,9 @@ export type FetchRequestStatus =
| "completed" | "completed"
| "failed"; | "failed";
export type SourceType = "file" | "pdf" | "xlsx" | "csv" | "email";
export interface FileSource { export interface FileSource {
type?: SourceType;
path: string; path: string;
bank: string; format: string;
raw_lines?: string[]; raw_lines?: string[];
txn_blocks?: Record<string, any>; txn_blocks?: Record<string, any>;
txn_dicts?: Record<string, any>[]; txn_dicts?: Record<string, any>[];
@@ -20,21 +17,8 @@ export interface FileSource {
txn_dicts_count?: number; txn_dicts_count?: number;
} }
export interface PdfSource extends FileSource {
type: "pdf";
}
export interface XlsxSource extends FileSource {
type: "xlsx";
}
export interface CsvSource extends FileSource {
type: "csv";
}
export interface EmailSource { export interface EmailSource {
type: "email"; format: string;
bank: string;
from_email?: string; from_email?: string;
subject?: string; subject?: string;
raw_terms?: string[]; raw_terms?: string[];
@@ -42,15 +26,9 @@ export interface EmailSource {
txn_dicts_count?: number; txn_dicts_count?: number;
} }
export type PipelineType = "heuristic" | "llm" | "llama_parse";
export type TrustFallback = "amount" | "balance";
export interface FetchRequestCreate { export interface FetchRequestCreate {
source: PdfSource | XlsxSource | CsvSource | EmailSource; source: FileSource | EmailSource;
account_name: string; account_name: string;
pipeline?: PipelineType;
trust_fallback?: TrustFallback | null;
payor_username?: string; payor_username?: string;
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;
@@ -132,7 +110,7 @@ export interface SSEEvent {
export interface FetchRequestFilters { export interface FetchRequestFilters {
status?: FetchRequestStatus[]; status?: FetchRequestStatus[];
account_name?: string; account_name?: string;
source_type?: SourceType; source_type?: "file" | "email";
} }
export function formatApiError(err: any): string { export function formatApiError(err: any): string {

View File

@@ -5,9 +5,6 @@ export type {
FetchRequestStatus, FetchRequestStatus,
FetchRequestFilters, FetchRequestFilters,
FileSource, FileSource,
PdfSource,
XlsxSource,
SourceType,
EmailSource, EmailSource,
UploadResult, UploadResult,
PendingAmbiguity, PendingAmbiguity,
@@ -17,8 +14,6 @@ export type {
SSEEventStep, SSEEventStep,
SSEEventStatus, SSEEventStatus,
ProgressMessage, ProgressMessage,
PipelineType,
TrustFallback,
} from "./fetch-requests.models"; } from "./fetch-requests.models";
export { RETRY_MAX, formatApiError } from "./fetch-requests.models"; export { RETRY_MAX, formatApiError } from "./fetch-requests.models";
export { export {

View File

@@ -5,26 +5,21 @@ import {
BrowserRouter, BrowserRouter,
Routes, Routes,
Route, Route,
useNavigate, useNavigate
useLocation,
matchPath
} from "react-router-dom"; } from "react-router-dom";
import { import {
Box, Box,
CssBaseline, CssBaseline,
CircularProgress Toolbar
} from "@mui/material"; } from "@mui/material";
import Home from './Home'; import Home from './Home';
import FetchRequests from './FetchRequest/FetchRequestCreate'; import FetchRequests from './FetchRequest/FetchRequestCreate';
import FetchRequestDetail from './FetchRequest/FetchRequestDetail'; import FetchRequestDetail from './FetchRequest/FetchRequestDetail';
import Expense from './Expense';
import Reports from './Reports/Report';
import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi'; import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi';
import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth"; import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth";
import Header from './Header'; import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
import AppTheme from './shared-theme/AppTheme'; import AppTheme from './shared-theme/AppTheme';
import { ToastProvider } from './ui/Toast';
import { specConfiguration } from './openapi-config'; import { specConfiguration } from './openapi-config';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -80,70 +75,35 @@ const routerMapping = [
{ path: "/register", component: RegisterPage, headerTitle: "Register" }, { path: "/register", component: RegisterPage, headerTitle: "Register" },
{ path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" }, { path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" },
{ path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" }, { path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" },
{ path: "/expenses", component: Expense, headerTitle: "Expenses" },
{ path: "/reports", component: Reports, headerTitle: "Reports" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" },
{ path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" }, { path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" },
]; ];
/** Reads authConfig from AppProvider context and passes it to AuthProvider. */ /** Reads authConfig from AppProvider context and passes it to AuthProvider. */
function AppContent() { function AppContent() {
const { authConfig, loading } = useAppContext(); const { authConfig } = useAppContext();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
React.useEffect(() => {
const matched = routerMapping.find((route) =>
matchPath({ path: route.path, end: false }, location.pathname),
);
document.title = matched
? `${matched.headerTitle} · Khata`
: "Khata — Financial Ledger";
}, [location.pathname]);
if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "100vh" }}>
<CircularProgress />
</Box>
);
}
return ( return (
<AuthProvider authConfig={authConfig} onUnauthorized={() => navigate("/login")}> <AuthProvider authConfig={authConfig} onUnauthorized={() => navigate("/login")}>
<AppTheme> <AppTheme>
<CssBaseline enableColorScheme /> <CssBaseline enableColorScheme />
<ToastProvider> <Header routerMapping={routerMapping} />
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
<Header routerMapping={routerMapping} />
<Box <Box sx={{ pb: 8 }}>
component="main" <Toolbar />
key={location.pathname}
sx={{
flexGrow: 1,
width: "100%",
animation: "fadeIn 220ms ease-out",
"@keyframes fadeIn": {
from: { opacity: 0, transform: "translateY(4px)" },
to: { opacity: 1, transform: "translateY(0)" },
},
}}
>
<Routes>
{routerMapping.map(({ path, component: Component }) => (
<Route
key={path}
path={path}
element={<Component basePath={path.replace(/\/\*$/, "")} />}
/>
))}
</Routes>
</Box>
<Footer /> <Routes>
</Box> {routerMapping.map(({ path, component: Component }) => (
</ToastProvider> <Route
key={path}
path={path}
element={<Component basePath={path.replace(/\/\*$/, "")} />}
/>
))}
</Routes>
</Box>
<Footer />
</AppTheme> </AppTheme>
</AuthProvider> </AuthProvider>
); );

View File

@@ -11,7 +11,6 @@ export const specConfiguration: SpecConfiguration = {
resourceConfig: { resourceConfig: {
expenses: { expenses: {
filterOptions: { mode: "client" }, filterOptions: { mode: "client" },
fieldTypes: { amount: "currency" },
}, },
}, },
}; };

View File

@@ -1,14 +1,21 @@
import { Theme, alpha, Components } from '@mui/material/styles'; import { Theme, alpha, Components } from '@mui/material/styles';
import { gray } from '../themePrimitives'; import { gray, orange } from '../themePrimitives';
/* eslint-disable import/prefer-default-export */ /* eslint-disable import/prefer-default-export */
export const feedbackCustomizations: Components<Theme> = { export const feedbackCustomizations: Components<Theme> = {
MuiAlert: { MuiAlert: {
styleOverrides: { styleOverrides: {
root: ({ theme }) => ({ root: ({ theme }) => ({
borderRadius: (theme.vars || theme).shape.borderRadius, borderRadius: 10,
backgroundColor: orange[100],
color: (theme.vars || theme).palette.text.primary,
border: `1px solid ${alpha(orange[300], 0.5)}`,
'& .MuiAlert-icon': {
color: orange[500],
},
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
backgroundColor: alpha((theme.vars || theme).palette.background.paper, 0.6), backgroundColor: alpha(orange[900], 0.35),
border: `1px solid ${alpha(orange[800], 0.3)}`,
}), }),
}), }),
}, },
@@ -17,7 +24,7 @@ export const feedbackCustomizations: Components<Theme> = {
styleOverrides: { styleOverrides: {
root: ({ theme }) => ({ root: ({ theme }) => ({
'& .MuiDialog-paper': { '& .MuiDialog-paper': {
borderRadius: (theme.vars || theme).shape.borderRadius, borderRadius: '10px',
border: '1px solid', border: '1px solid',
borderColor: (theme.vars || theme).palette.divider, borderColor: (theme.vars || theme).palette.divider,
}, },

View File

@@ -58,26 +58,31 @@ export const inputsCustomizations: Components<Theme> = {
}, },
style: { style: {
color: 'white', color: 'white',
backgroundColor: brand[500], backgroundColor: gray[900],
border: `1px solid ${brand[600]}`, backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`,
boxShadow: 'none', boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`,
border: `1px solid ${gray[700]}`,
'&:hover': { '&:hover': {
backgroundColor: brand[600], backgroundImage: 'none',
backgroundColor: gray[700],
boxShadow: 'none', boxShadow: 'none',
}, },
'&:active': { '&:active': {
backgroundColor: brand[700], backgroundColor: gray[800],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
color: brand[50], color: 'black',
backgroundColor: brand[500], backgroundColor: gray[50],
border: `1px solid ${brand[600]}`, backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`,
boxShadow: 'inset 0 -1px 0 hsl(220, 30%, 80%)',
border: `1px solid ${gray[50]}`,
'&:hover': { '&:hover': {
backgroundColor: brand[400], backgroundImage: 'none',
backgroundColor: gray[300],
boxShadow: 'none', boxShadow: 'none',
}, },
'&:active': { '&:active': {
backgroundColor: brand[300], backgroundColor: gray[400],
}, },
}), }),
}, },
@@ -89,28 +94,18 @@ export const inputsCustomizations: Components<Theme> = {
}, },
style: { style: {
color: 'white', color: 'white',
backgroundColor: gray[900], backgroundColor: brand[300],
border: `1px solid ${gray[700]}`, backgroundImage: `linear-gradient(to bottom, ${alpha(brand[400], 0.8)}, ${brand[500]})`,
boxShadow: 'none', boxShadow: `inset 0 2px 0 ${alpha(brand[200], 0.2)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`,
border: `1px solid ${brand[500]}`,
'&:hover': { '&:hover': {
backgroundColor: gray[800], backgroundColor: brand[700],
boxShadow: 'none', boxShadow: 'none',
}, },
'&:active': { '&:active': {
backgroundColor: gray[700], backgroundColor: brand[700],
backgroundImage: 'none',
}, },
...theme.applyStyles('dark', {
color: 'black',
backgroundColor: gray[50],
border: `1px solid ${gray[300]}`,
'&:hover': {
backgroundColor: gray[200],
boxShadow: 'none',
},
'&:active': {
backgroundColor: gray[300],
},
}),
}, },
}, },
{ {

View File

@@ -36,7 +36,7 @@ export const surfacesCustomizations: Components<Theme> = {
styleOverrides: { styleOverrides: {
root: ({ theme }) => ({ root: ({ theme }) => ({
border: 'none', border: 'none',
borderRadius: (theme.vars || theme).shape.borderRadius, borderRadius: 8,
'&:hover': { backgroundColor: gray[50] }, '&:hover': { backgroundColor: gray[50] },
'&:focus-visible': { backgroundColor: 'transparent' }, '&:focus-visible': { backgroundColor: 'transparent' },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {

View File

@@ -35,37 +35,32 @@ const defaultTheme = createTheme();
const customShadows: Shadows = [...defaultTheme.shadows]; const customShadows: Shadows = [...defaultTheme.shadows];
/**
* Stripe-like indigo brand. #635BFF ≈ hsl(243, 76%, 59%).
* Single brand hue — no multi-colour accents.
*/
export const brand = { export const brand = {
50: 'hsl(250, 100%, 98%)', 50: 'hsl(210, 100%, 95%)',
100: 'hsl(250, 95%, 95%)', 100: 'hsl(210, 100%, 92%)',
200: 'hsl(249, 92%, 90%)', 200: 'hsl(210, 100%, 80%)',
300: 'hsl(248, 92%, 80%)', 300: 'hsl(210, 100%, 65%)',
400: 'hsl(245, 90%, 72%)', 400: 'hsl(210, 98%, 48%)',
500: 'hsl(243, 76%, 59%)', 500: 'hsl(210, 98%, 42%)',
600: 'hsl(243, 75%, 51%)', 600: 'hsl(210, 98%, 55%)',
700: 'hsl(243, 78%, 42%)', 700: 'hsl(210, 100%, 35%)',
800: 'hsl(242, 76%, 30%)', 800: 'hsl(210, 100%, 16%)',
900: 'hsl(241, 75%, 20%)', 900: 'hsl(210, 100%, 21%)',
}; };
/** Cool, neutral grays. */
export const gray = { export const gray = {
50: 'hsl(220, 35%, 97%)', 50: 'hsl(220, 35%, 97%)',
100: 'hsl(220, 30%, 94%)', 100: 'hsl(220, 30%, 94%)',
200: 'hsl(220, 20%, 89%)', 200: 'hsl(220, 20%, 88%)',
300: 'hsl(220, 17%, 80%)', 300: 'hsl(220, 20%, 80%)',
400: 'hsl(220, 15%, 64%)', 400: 'hsl(220, 20%, 65%)',
500: 'hsl(220, 14%, 45%)', 500: 'hsl(220, 20%, 42%)',
600: 'hsl(220, 13%, 36%)', 600: 'hsl(220, 20%, 35%)',
700: 'hsl(220, 13%, 27%)', 700: 'hsl(220, 20%, 25%)',
750: 'hsl(220, 13%, 20%)', 750: 'hsl(220, 20%, 18%)',
800: 'hsl(220, 12%, 12%)', 800: 'hsl(220, 30%, 6%)',
850: 'hsl(220, 10%, 8%)', 850: 'hsl(220, 22%, 11%)',
900: 'hsl(220, 9%, 6%)', 900: 'hsl(220, 35%, 3%)',
}; };
export const green = { export const green = {
@@ -121,39 +116,27 @@ export const getDesignTokens = (mode: PaletteMode) => {
palette: { palette: {
mode, mode,
primary: { primary: {
light: brand[400], light: brand[200],
main: brand[500], main: brand[400],
dark: brand[700], dark: brand[700],
contrastText: brand[50], contrastText: brand[50],
...(mode === 'dark' && { ...(mode === 'dark' && {
contrastText: brand[50], contrastText: brand[50],
light: 'hsl(245, 85%, 70%)', light: 'hsl(210, 50%, 65%)',
main: 'hsl(243, 76%, 64%)', main: 'hsl(210, 55%, 55%)',
dark: 'hsl(243, 78%, 48%)', dark: 'hsl(210, 50%, 35%)',
}),
},
secondary: {
light: gray[300],
main: gray[600],
dark: gray[800],
contrastText: gray[50],
...(mode === 'dark' && {
light: gray[200],
main: gray[400],
dark: gray[600],
contrastText: gray[900],
}), }),
}, },
info: { info: {
light: brand[100], light: brand[100],
main: brand[400], main: brand[300],
dark: brand[600], dark: brand[600],
contrastText: gray[50], contrastText: gray[50],
...(mode === 'dark' && { ...(mode === 'dark' && {
contrastText: 'hsl(210, 30%, 80%)', contrastText: 'hsl(210, 30%, 80%)',
light: 'hsl(245, 60%, 60%)', light: 'hsl(210, 40%, 50%)',
main: 'hsl(243, 55%, 52%)', main: 'hsl(210, 35%, 40%)',
dark: 'hsl(243, 50%, 38%)', dark: 'hsl(210, 30%, 25%)',
}), }),
}, },
warning: { warning: {
@@ -191,7 +174,7 @@ export const getDesignTokens = (mode: PaletteMode) => {
}, },
divider: mode === 'dark' ? 'hsla(0, 0%, 100%, 0.08)' : alpha(gray[300], 0.4), divider: mode === 'dark' ? 'hsla(0, 0%, 100%, 0.08)' : alpha(gray[300], 0.4),
background: { background: {
default: 'hsl(0, 0%, 100%)', default: 'hsl(0, 0%, 99%)',
paper: 'hsl(220, 35%, 97%)', paper: 'hsl(220, 35%, 97%)',
...(mode === 'dark' && { default: darkBg, paper: darkPaper }), ...(mode === 'dark' && { default: darkBg, paper: darkPaper }),
}, },
@@ -225,73 +208,242 @@ export const getDesignTokens = (mode: PaletteMode) => {
typography: { typography: {
fontFamily: 'Inter, sans-serif', fontFamily: 'Inter, sans-serif',
h1: { h1: {
fontSize: defaultTheme.typography.pxToRem(40), fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600, fontWeight: 600,
lineHeight: 1.1, lineHeight: 1.2,
letterSpacing: '-0.025em', letterSpacing: -0.5,
}, },
h2: { h2: {
fontSize: defaultTheme.typography.pxToRem(32), fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600, fontWeight: 600,
lineHeight: 1.2, lineHeight: 1.2,
letterSpacing: '-0.02em',
}, },
h3: { h3: {
fontSize: defaultTheme.typography.pxToRem(26), fontSize: defaultTheme.typography.pxToRem(30),
fontWeight: 600, lineHeight: 1.2,
lineHeight: 1.25,
letterSpacing: '-0.015em',
}, },
h4: { h4: {
fontSize: defaultTheme.typography.pxToRem(20), fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.4,
letterSpacing: '-0.01em',
},
h5: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
lineHeight: 1.4,
letterSpacing: '-0.005em',
},
h6: {
fontSize: defaultTheme.typography.pxToRem(16),
fontWeight: 600, fontWeight: 600,
lineHeight: 1.5, lineHeight: 1.5,
}, },
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: { subtitle1: {
fontSize: defaultTheme.typography.pxToRem(16), fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 500,
}, },
subtitle2: { subtitle2: {
fontSize: defaultTheme.typography.pxToRem(13), fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500, fontWeight: 500,
}, },
body1: { body1: {
fontSize: defaultTheme.typography.pxToRem(15), fontSize: defaultTheme.typography.pxToRem(14),
lineHeight: 1.5,
}, },
body2: { body2: {
fontSize: defaultTheme.typography.pxToRem(14), fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400, fontWeight: 400,
lineHeight: 1.45,
}, },
caption: { caption: {
fontSize: defaultTheme.typography.pxToRem(12), fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400, fontWeight: 400,
lineHeight: 1.4,
},
overline: {
fontSize: defaultTheme.typography.pxToRem(11),
fontWeight: 600,
lineHeight: 1.5,
textTransform: 'uppercase',
letterSpacing: '0.06em',
}, },
}, },
shape: { shape: {
borderRadius: 6, borderRadius: 8,
}, },
shadows: customShadows, shadows: customShadows,
}; };
}; };
export const colorSchemes = {
light: {
palette: {
primary: {
light: brand[200],
main: brand[400],
dark: brand[700],
contrastText: brand[50],
},
info: {
light: brand[100],
main: brand[300],
dark: brand[600],
contrastText: gray[50],
},
warning: {
light: orange[300],
main: orange[400],
dark: orange[800],
},
error: {
light: red[300],
main: red[400],
dark: red[800],
},
success: {
light: green[300],
main: green[400],
dark: green[800],
},
grey: {
...gray,
},
divider: alpha(gray[300], 0.4),
background: {
default: 'hsl(0, 0%, 99%)',
paper: 'hsl(220, 35%, 97%)',
},
text: {
primary: gray[800],
secondary: gray[600],
warning: orange[400],
},
action: {
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',
},
},
dark: {
palette: {
primary: {
contrastText: brand[50],
light: 'hsl(210, 50%, 65%)',
main: 'hsl(210, 55%, 55%)',
dark: 'hsl(210, 50%, 35%)',
},
info: {
contrastText: 'hsl(210, 30%, 80%)',
light: 'hsl(210, 40%, 50%)',
main: 'hsl(210, 35%, 40%)',
dark: 'hsl(210, 30%, 25%)',
},
warning: {
light: 'hsl(45, 60%, 55%)',
main: 'hsl(45, 55%, 45%)',
dark: 'hsl(45, 50%, 30%)',
},
error: {
light: 'hsl(0, 55%, 60%)',
main: 'hsl(0, 55%, 50%)',
dark: 'hsl(0, 50%, 35%)',
},
success: {
light: 'hsl(120, 40%, 55%)',
main: 'hsl(120, 40%, 45%)',
dark: 'hsl(120, 35%, 30%)',
},
grey: {
...gray,
},
divider: 'hsla(0, 0%, 100%, 0.08)',
background: {
default: darkBg,
paper: darkPaper,
},
text: {
primary: 'hsl(0, 0%, 92%)',
secondary: 'hsl(0, 0%, 60%)',
},
action: {
hover: 'hsla(0, 0%, 100%, 0.06)',
selected: 'hsla(0, 0%, 100%, 0.1)',
},
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)',
},
},
};
export const typography = {
fontFamily: 'Inter, sans-serif',
h1: {
fontSize: defaultTheme.typography.pxToRem(48),
fontWeight: 600,
lineHeight: 1.2,
letterSpacing: -0.5,
},
h2: {
fontSize: defaultTheme.typography.pxToRem(36),
fontWeight: 600,
lineHeight: 1.2,
},
h3: {
fontSize: defaultTheme.typography.pxToRem(30),
lineHeight: 1.2,
},
h4: {
fontSize: defaultTheme.typography.pxToRem(24),
fontWeight: 600,
lineHeight: 1.5,
},
h5: {
fontSize: defaultTheme.typography.pxToRem(20),
fontWeight: 600,
},
h6: {
fontSize: defaultTheme.typography.pxToRem(18),
fontWeight: 600,
},
subtitle1: {
fontSize: defaultTheme.typography.pxToRem(18),
},
subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 500,
},
body1: {
fontSize: defaultTheme.typography.pxToRem(14),
},
body2: {
fontSize: defaultTheme.typography.pxToRem(14),
fontWeight: 400,
},
caption: {
fontSize: defaultTheme.typography.pxToRem(12),
fontWeight: 400,
},
};
export const shape = {
borderRadius: 8,
};
// @ts-ignore
const defaultShadows: Shadows = [
'none',
'var(--template-palette-baseShadow)',
...defaultTheme.shadows.slice(2),
];
export const shadows = defaultShadows;

View File

@@ -1,57 +0,0 @@
import * as React from "react";
import { Box, Typography, Button } from "@mui/material";
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
actionLabel?: string;
onAction?: () => void;
}
export function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
py: 6,
px: 3,
}}
>
{icon && (
<Box
sx={{
width: 48,
height: 48,
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "action.hover",
color: "text.secondary",
mb: 2,
}}
>
{icon}
</Box>
)}
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
{title}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 360, lineHeight: 1.6, mb: 2 }}>
{description}
</Typography>
)}
{actionLabel && onAction && (
<Button variant="contained" size="small" onClick={onAction} sx={{ mt: 1 }}>
{actionLabel}
</Button>
)}
</Box>
);
}

View File

@@ -1,67 +0,0 @@
import * as React from "react";
import { Box, Breadcrumbs, Typography, Link } from "@mui/material";
import { useNavigate } from "react-router-dom";
import NavigateNextIcon from "@mui/icons-material/NavigateNext";
export interface Crumb {
label: string;
path?: string;
}
interface PageHeaderProps {
crumbs: Crumb[];
title: string;
subtitle?: string;
actions?: React.ReactNode;
}
export function PageHeader({ crumbs, title, subtitle, actions }: PageHeaderProps) {
const navigate = useNavigate();
return (
<Box sx={{ mb: 3 }}>
<Breadcrumbs
separator={<NavigateNextIcon fontSize="small" sx={{ color: "text.disabled" }} />}
aria-label="breadcrumb"
sx={{ mb: 1 }}
>
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
if (isLast || !crumb.path) {
return (
<Typography key={crumb.label} variant="body2" color="text.secondary">
{crumb.label}
</Typography>
);
}
return (
<Link
key={crumb.label}
component="button"
underline="hover"
color="text.primary"
onClick={() => navigate(crumb.path!)}
sx={{ fontWeight: 500, fontSize: "0.875rem" }}
>
{crumb.label}
</Link>
);
})}
</Breadcrumbs>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="h4" fontWeight={700} sx={{ letterSpacing: "-0.02em" }}>
{title}
</Typography>
{subtitle && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{subtitle}
</Typography>
)}
</Box>
{actions && <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>{actions}</Box>}
</Box>
</Box>
);
}

View File

@@ -1,52 +0,0 @@
import * as React from "react";
import { Snackbar, Alert, AlertColor } from "@mui/material";
interface ToastState {
open: boolean;
message: string;
severity: AlertColor;
}
interface ToastContextValue {
showToast: (message: string, severity?: AlertColor) => void;
}
const ToastContext = React.createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toast, setToast] = React.useState<ToastState>({ open: false, message: "", severity: "success" });
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const showToast = React.useCallback((message: string, severity: AlertColor = "success") => {
if (timerRef.current) clearTimeout(timerRef.current);
setToast({ open: true, message, severity });
timerRef.current = setTimeout(() => setToast((t) => ({ ...t, open: false })), 4000);
}, []);
const handleClose = (_?: React.SyntheticEvent | Event, reason?: string) => {
if (reason === "clickaway") return;
setToast((t) => ({ ...t, open: false }));
};
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<Snackbar
open={toast.open}
autoHideDuration={4000}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert severity={toast.severity} onClose={handleClose} sx={{ borderRadius: 2, minWidth: 260 }}>
{toast.message}
</Alert>
</Snackbar>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = React.useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within a ToastProvider");
return ctx;
}