Stripe-style UI overhaul + Expenses feed with month grouping (#15)

## Title
Stripe-style UI overhaul + Expenses feed with month grouping

## Summary
Rework the khata-ui frontend to a Stripe-grade design system and add a
rich, monthly-grouped Expenses experience driven by react-openapi's
shared, spec-configured field renderers.

## Highlights

### Design overhaul
- New design system: single indigo (#635BFF), 6px radius, Inter type
  scale, streamlined surfaces/inputs/feedback tokens
- Sticky app bar with brand-left header + inline footer, route titles
- Split-panel auth pages, Stripe-style Home hero + feature cards
- Toast provider + shared PageHeader/EmptyState UI primitives
- Admin polish: right-aligned numerics, row-hover actions, skeletons,
  richer empty states, breadcrumbed fetch-request flows

### Expenses page
- Monthly grouped feed with stat cards (total spent / this month / income)
- Month cards as single-open accordions with red-spent / green-income totals
- Flat transaction rows: logo, name/date, account, amount (no accordion)
- Floating month–year selector pill (scroll-spy) that expands + scrolls
  to the selected month
- Strict DD-MM-YYYY date parsing (day-first), no format guessing

### Shared react-openapi fields
- New `CurrencyField` + `formatCurrency` (cached Intl, sign-colored,
  sign-less amounts; form editing reuses NumberField)
- `resourceConfig.fieldTypes` override mechanism (amount → currency)
- Expenses page rendered via ListCellRenderer / DetailFieldRenderer /
  applyDisplayFormat — zero custom field renderers
- `resolveMediaUrl` resolves relative `/uploads/...` against the API
  base for every image field (Admin + Expenses logos)

### Fixes
- Perf: memoized rows, formatter caches, single-open accordions —
  cuts slow-click INP and DOM-nesting warnings
- Removed unused @mui/x-data-grid@7, which hoisted @mui/system@7 and
  crashed Box/createTheme at runtime (v5/v7 mix)

## Commits (10)
- 9808a1f design overhaul + expenses page
- d6856a5 fix expenses list performance + dom nesting
- 3dd833a shared currency field + expenses rendered from react-openapi fields
- e8c585b fix expenses grouping to parse occurred_at as DD-MM-YYYY strictly
- cc940ee expense list: split month totals, sticky headers + month pill
- 47d799f expense list: clickable month pill + fix scroll-to-month
- 537aef9 resolve relative media URLs against the API base in react-openapi
- 8e9a13d expense list: replace accordion with flat Stripe-style rows
- 62dd06c expense list: month accordions + month-year selector; drop unused x-data-grid
- 806bd42 dropped amount sign from CurrencyField.tsx

Reviewed-on: #15
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
This commit is contained in:
2026-08-18 13:51:28 +00:00
committed by aetos
parent 51762f8d18
commit 25ec534597
37 changed files with 1805 additions and 1067 deletions

View File

@@ -3,17 +3,19 @@
<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=Roboto:wght@300;400;500;700&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
/> />
<link rel="icon" type="image/png" href="/favicon.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<title>khata - Aetoskia</title> <title>Khata — Financial Ledger</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,7 +13,6 @@
"@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",
@@ -1097,201 +1096,6 @@
} }
} }
}, },
"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",
@@ -1334,62 +1138,6 @@
} }
} }
}, },
"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",
@@ -4174,11 +3922,6 @@
"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",
@@ -4516,14 +4259,6 @@
"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,7 +13,6 @@
"@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,6 +1,20 @@
import * as React from 'react'; import * as React from 'react';
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material'; import {
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";
@@ -15,6 +29,12 @@ 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,
@@ -25,114 +45,200 @@ 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]);
return ( const validate = (): boolean => {
<Box const next: { username?: string; password?: string } = {};
sx={{ if (!username.trim()) next.username = "Username is required";
maxWidth: 400, else if (username.trim().length < 3) next.username = "Username must be at least 3 characters";
mx: 'auto', if (!password) next.password = "Password is required";
mt: 8, else if (password.length < 6) next.password = "Password must be at least 6 characters";
p: 4, setFieldErrors(next);
borderRadius: 3, return Object.keys(next).length === 0;
boxShadow: 3, };
bgcolor: 'background.paper',
}} const handleSubmit = async (e: React.FormEvent) => {
> e.preventDefault();
<IconButton onClick={onBack} sx={{ mb: 2 }}> if (!validate()) return;
<ArrowBackRoundedIcon /> if (isLogin) {
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="bold" gutterBottom> <Typography variant="h4" fontWeight={700} gutterBottom sx={{ letterSpacing: '-0.02em' }}>
{isLogin ? "Sign In" : "Create Account"} {isLogin ? "Sign in to Khata" : "Create your account"}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" gutterBottom> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{isLogin {isLogin
? "Please log in to continue" ? "Welcome back. Enter your credentials to continue."
: "Create an account to get started"} : "Start managing your ledger in a few seconds."}
</Typography> </Typography>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} noValidate>
<TextField <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
fullWidth <TextField
label="Username" fullWidth
type="username" label="Username"
margin="normal" type="text"
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => {
required setUsername(e.target.value);
autoFocus if (fieldErrors.username) setFieldErrors((p) => ({ ...p, username: undefined }));
/> }}
<TextField error={!!fieldErrors.username}
fullWidth helperText={fieldErrors.username}
label="Password" autoComplete="username"
type="password" autoFocus
margin="normal" />
value={password} <TextField
onChange={(e) => setPassword(e.target.value)} fullWidth
required label="Password"
/> 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: 1 }}> <Typography color="error" variant="body2" sx={{ mt: 0.5 }}>
{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 <Typography variant="body2" color="text.secondary" align="center" sx={{ mt: 3 }}>
variant="body2" {isLogin ? "Don't have an account?" : "Already have an account?"}{' '}
color="text.secondary" <Link component="button" underline="hover" color="primary" onClick={handleSwitch} sx={{ fontWeight: 600 }}>
align="center" {isLogin ? "Register" : "Sign In"}
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 @mui/x-data-grid @emotion/react @emotion/styled axios js-yaml npm install react react-dom react-router-dom @mui/material @mui/icons-material @emotion/react @emotion/styled axios js-yaml
``` ```
## Quick Start ## Quick Start

View File

@@ -5,6 +5,7 @@ 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 { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField";
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";

View File

@@ -11,7 +11,6 @@
"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,9 +58,14 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
if (!data) { if (!data) {
return ( return (
<Typography variant="body1" color="text.secondary" sx={{ py: 4 }}> <Box sx={{ py: 6, textAlign: "center" }}>
Record not found <Typography variant="body2" color="text.secondary">
</Typography> Record not found
</Typography>
<Button sx={{ mt: 1.5 }} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back to list
</Button>
</Box>
); );
} }
@@ -82,7 +87,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 }}> <Typography variant="h5" fontWeight={700} sx={{ flex: 1, letterSpacing: "-0.02em" }}>
{applyDisplayFormat( {applyDisplayFormat(
Object.fromEntries( Object.fromEntries(
resource.orderedFields.map((field) => { resource.orderedFields.map((field) => {

View File

@@ -20,11 +20,13 @@ 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";
@@ -38,6 +40,10 @@ 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;
@@ -265,12 +271,16 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
/> />
)} )}
<TableContainer component={Paper} variant="outlined"> <TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 3 }}>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
{visibleColumns.map((col) => ( {visibleColumns.map((col) => (
<TableCell key={col.name} sx={{ fontWeight: 700 }}> <TableCell
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}
@@ -284,16 +294,45 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
)} )}
</TableCell> </TableCell>
))} ))}
{hasActions && <TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>} {hasActions && <TableCell align="right" sx={{ fontWeight: 700, fontSize: "0.75rem", py: 1 }}>Actions</TableCell>}
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{displayData.length === 0 ? ( {crud.loading && 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">
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}> <Box sx={{ py: 5, display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
{isStreaming ? "Waiting for events\u2026" : "No records found"} <StorageIcon sx={{ fontSize: 32, color: "text.disabled" }} />
</Typography> <Typography variant="body2" color="text.secondary">
{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>
) : ( ) : (
@@ -303,7 +342,7 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
<TableRow <TableRow
key={rowId} key={rowId}
hover hover
sx={{ cursor: "pointer" }} sx={{ cursor: "pointer", "&:hover .row-actions": { opacity: 1 } }}
onClick={() => { onClick={() => {
if (isStreaming) { if (isStreaming) {
setDetailRow(row); setDetailRow(row);
@@ -322,34 +361,56 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
fmt = col.inlineDisplayFormat; fmt = col.inlineDisplayFormat;
} }
return ( return (
<TableCell key={col.name}> <TableCell
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 align="right" onClick={(e) => e.stopPropagation()}> <TableCell
{resource.operations.get && !isStreaming && ( align="right"
<Tooltip title="View"> onClick={(e) => e.stopPropagation()}
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}> >
<VisibilityIcon fontSize="small" /> <Box
</IconButton> className="row-actions"
</Tooltip> sx={{
)} opacity: { xs: 1, md: 0 },
{resource.operations.update && ( display: "flex",
<Tooltip title="Edit"> justifyContent: "flex-end",
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}> gap: 0.5,
<EditIcon fontSize="small" /> transition: "opacity 160ms ease",
</IconButton> }}
</Tooltip> >
)} {resource.operations.get && !isStreaming && (
{resource.operations.delete && ( <Tooltip title="View">
<Tooltip title="Delete"> <IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error"> <VisibilityIcon fontSize="small" />
<DeleteIcon fontSize="small" /> </IconButton>
</IconButton> </Tooltip>
</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,11 +30,6 @@ 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>
@@ -43,7 +38,7 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
</Typography> </Typography>
</Toolbar> </Toolbar>
<List sx={{ px: 1 }}> <List sx={{ px: 1 }}>
{resources.map((r, i) => { {resources.map((r) => {
const listPath = `${basePath}/${r.name}`; const listPath = `${basePath}/${r.name}`;
const active = location.pathname.startsWith(listPath); const active = location.pathname.startsWith(listPath);
return ( return (
@@ -58,13 +53,15 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
borderRadius: 2, borderRadius: 2,
mb: 0.5, mb: 0.5,
"&.Mui-selected": { "&.Mui-selected": {
bgcolor: `${colors[i % colors.length]}15`, bgcolor: "primary.main",
"&:hover": { bgcolor: `${colors[i % colors.length]}20` }, color: "primary.contrastText",
"&:hover": { bgcolor: "primary.main" },
"& .MuiListItemIcon-root": { color: "primary.contrastText" },
}, },
}} }}
> >
<ListItemIcon sx={{ minWidth: 36 }}> <ListItemIcon sx={{ minWidth: 36 }}>
<CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} /> <CircleIcon sx={{ fontSize: 12 }} />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary={r.displayName} primary={r.displayName}

View File

@@ -2,6 +2,9 @@ import React from "react";
import { Box, Typography, Avatar } from "@mui/material"; import { Box, Typography, Avatar } 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;
@@ -11,6 +14,7 @@ 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 (
@@ -18,8 +22,10 @@ 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 === "image" ? ( {field.uiType === "currency" ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} /> <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} /> <ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
)} )}

View File

@@ -96,6 +96,17 @@ 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,8 +2,9 @@ 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 } from "./utils"; import { applyDisplayFormat, resolveMediaUrl } from "./utils";
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";
@@ -16,7 +17,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 } = useAppContext(); const { schemas, config } = useAppContext();
const [inlineItem, setInlineItem] = useState<any>(null); const [inlineItem, setInlineItem] = useState<any>(null);
if (value === null || value === undefined) { if (value === null || value === undefined) {
@@ -137,7 +138,11 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List
} }
if (field.uiType === "image" && value) { if (field.uiType === "image" && value) {
return <Avatar src={value} variant="rounded" sx={{ width: 40, height: 40 }} />; return <Avatar src={resolveMediaUrl(value, config.baseApiUrl)} 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") {

View File

@@ -3,3 +3,4 @@ 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 { CurrencyField, formatCurrency } from "./renderers/CurrencyField";

View File

@@ -0,0 +1,42 @@
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

@@ -2,6 +2,8 @@ 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;
@@ -19,6 +21,7 @@ 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];
@@ -51,12 +54,12 @@ export function FileUploadField({ field, value, onChange }: Props) {
</Typography> </Typography>
{value ? ( {value ? (
uploadConfig.type === "image" ? ( uploadConfig.type === "image" ? (
<Avatar src={`/uploads/${value}`} variant="rounded" sx={{ width: 120, height: 120 }} /> <Avatar src={resolveMediaUrl(`/uploads/${value}`, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
) : ( ) : (
<Chip <Chip
label={value} label={value}
component="a" component="a"
href={`/uploads/${value}`} href={resolveMediaUrl(`/uploads/${value}`, config.baseApiUrl)}
clickable clickable
onDelete={handleReplace} onDelete={handleReplace}
/> />

View File

@@ -3,6 +3,8 @@ 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;
@@ -13,6 +15,7 @@ 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;
@@ -47,7 +50,7 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
{field.label} {field.label}
</Typography> </Typography>
{value ? ( {value ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} /> <Avatar src={resolveMediaUrl(value, config.baseApiUrl)} 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

@@ -9,3 +9,15 @@ export function applyDisplayFormat(item: any, format: string): string {
return val != null ? String(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,6 +43,20 @@ 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",
@@ -88,7 +102,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(configs); setResources(applyResourceOverrides(configs, specConfiguration));
} }
const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? ""; const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? "";

View File

@@ -4,6 +4,8 @@ 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 {

169
src/Expense/Expense.tsx Normal file
View File

@@ -0,0 +1,169 @@
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 { ExpenseList } from "./ExpenseList";
import { ExpenseItem, ExpenseFieldConfigs, isExpense, currentMonthKey, monthKey, monthLabel, parseOccurredAt } from "./types";
function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
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" }}>
{value}
</Typography>
{hint && (
<Typography variant="caption" color="text.secondary">
{hint}
</Typography>
)}
</Paper>
);
}
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<ExpenseFieldConfigs | null>(() => {
if (!resource) return null;
const find = (name: string) => resource.fields.find((f) => f.name === name);
const entity = find("entity");
const amount = find("amount");
const account = find("account");
const tags = find("tags");
const occurredAt = find("occurred_at");
const entitiesRes = resources.find((r) => r.name === "entities");
const accountsRes = resources.find((r) => r.name === "accounts");
const tagsRes = resources.find((r) => r.name === "tags");
const logo = entitiesRes?.fields.find((f) => f.name === "logo");
if (!entity || !amount || !account || !tags || !occurredAt || !logo) return null;
return {
entity,
amount,
account,
tags,
occurredAt,
logo,
formats: {
entity: entitiesRes?.displayFormat ?? "{name}",
account: accountsRes?.displayFormat ?? "{name}",
tags: tagsRes?.displayFormat ?? "{name}",
},
};
}, [resource, resources]);
useEffect(() => {
let mounted = true;
list({ limit: 0 }).then((res) => {
if (!mounted) return;
setItems((res.items ?? []) as ExpenseItem[]);
});
return () => {
mounted = false;
};
}, []);
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="Total spent"
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="Income" value={formatCurrency(summary.totalIncome, summary.currency)} />
</Box>
{fieldConfigs && <ExpenseList items={sorted} fields={fieldConfigs} />}
</>
)}
</Container>
);
}

352
src/Expense/ExpenseList.tsx Normal file
View File

@@ -0,0 +1,352 @@
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 { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types";
interface GroupedMonth {
key: string;
items: ExpenseItem[];
spent: number;
income: number;
currency: string;
}
function groupByMonth(items: ExpenseItem[]): GroupedMonth[] {
const map = new Map<string, ExpenseItem[]>();
for (const item of items) {
const key = monthKey(item.occurred_at);
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, items: sorted, spent, income, currency };
})
.sort((a, b) => b.key.localeCompare(a.key));
}
interface ExpenseRowProps {
item: ExpenseItem;
currency: string;
fields: ExpenseFieldConfigs;
}
const ExpenseRow = React.memo(function ExpenseRow({ item, currency, fields }: ExpenseRowProps) {
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>
);
});
interface ExpenseListProps {
items: ExpenseItem[];
fields: ExpenseFieldConfigs;
}
export function ExpenseList({ items, fields }: ExpenseListProps) {
const [activeMonth, setActiveMonth] = useState<string | null>(null);
const [openMonth, setOpenMonth] = useState<string | null>(null);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
const groups = useMemo(() => groupByMonth(items), [items]);
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": {
alignItems: "center",
gap: 1.5,
minWidth: 0,
},
}}
>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{monthLabel(group.key)}
</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>
</AccordionSummary>
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{group.items.map((item) => (
<ExpenseRow
key={item.id}
item={item}
currency={group.currency}
fields={fields}
/>
))}
</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 ? monthLabel(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={monthLabel(group.key)}
secondary={`${group.items.length} transactions`}
/>
</MenuItem>
))}
</Menu>
</>
);
}
export { groupByMonth };

65
src/Expense/types.ts Normal file
View File

@@ -0,0 +1,65 @@
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 ExpenseFieldConfigs {
entity: FieldConfig;
amount: FieldConfig;
account: FieldConfig;
tags: FieldConfig;
occurredAt: FieldConfig;
logo: FieldConfig;
formats: {
entity: string;
account: string;
tags: string;
};
}
export function isExpense(item: ExpenseItem): boolean {
return (item.amount ?? 0) < 0;
}
export function parseOccurredAt(value?: string): Date {
const m = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/);
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 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" });
}

View File

@@ -4,8 +4,11 @@ import {
CircularProgress, 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 { 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";
const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"]; const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"];
@@ -29,25 +32,39 @@ function FetchRequestList() {
if (!rows) { if (!rows) {
return ( return (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> <Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress size={24} /> <CircularProgress size={28} />
</Box> </Box>
); );
} }
if (rows.length === 0) { if (rows.length === 0) {
return <Typography variant="body2" color="text.secondary">No fetch requests found.</Typography>; return (
<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 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{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={{ p: 2, cursor: "pointer", "&:hover": { borderColor: "primary.main" } }} sx={{
p: 2,
cursor: "pointer",
borderRadius: 2,
transition: "border-color 160ms ease, box-shadow 160ms ease",
"&:hover": { borderColor: "primary.main", boxShadow: 1 },
}}
onClick={() => navigate(`/fetch-requests/${row.id}`)} onClick={() => navigate(`/fetch-requests/${row.id}`)}
> >
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", alignItems: "center" }}> <Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
@@ -145,18 +162,25 @@ export default function FetchRequestCreate() {
return ( return (
<Container maxWidth="lg" sx={{ py: 4 }}> <Container maxWidth="lg" sx={{ py: 4 }}>
<Typography variant="h5" fontWeight={800} gutterBottom> <PageHeader
Fetch Requests crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests" }]}
</Typography> title="Fetch Requests"
subtitle="Import transactions from bank statements or email and track pipeline progress."
/>
<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" } }}> <Paper
<Box sx={{ ml: 0.5, mb: 2.5 }}> id="new-fetch-request"
<Typography variant="subtitle1" fontWeight={700}> variant="outlined"
New Fetch Request sx={{ p: 3, mb: 4, borderRadius: 3 }}
</Typography> >
</Box> <Typography variant="subtitle1" fontWeight={700} gutterBottom>
New Fetch Request
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3, maxWidth: 520, lineHeight: 1.6 }}>
Choose an account, pipeline, and a file or email source to kick off an import.
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, maxWidth: 480 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 2, maxWidth: 520 }}>
{formFields.map((field) => ( {formFields.map((field) => (
<FormFieldRenderer <FormFieldRenderer
key={field.name} key={field.name}
@@ -168,18 +192,18 @@ export default function FetchRequestCreate() {
))} ))}
</Box> </Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2, gap: 1 }}> <Box sx={{ display: "flex", justifyContent: "flex-end", mt: 3, gap: 1.5 }}>
<Button variant="outlined" onClick={() => { setFormData({}); setResult(null); }}> <Button variant="outlined" onClick={() => { setFormData({}); setResult(null); }}>
Reset Reset
</Button> </Button>
<Button variant="contained" onClick={handleSubmit} disabled={loading}> <Button variant="contained" onClick={handleSubmit} disabled={loading} sx={{ minWidth: 180 }}>
{loading ? <CircularProgress size={20} sx={{ mr: 0.5 }} /> : null} {loading ? <CircularProgress size={20} sx={{ mr: 0.5 }} /> : null}
Create Fetch Request Create Fetch Request
</Button> </Button>
</Box> </Box>
{result && ( {result && (
<Alert severity={result.severity} sx={{ mt: 1 }} onClose={() => setResult(null)}> <Alert severity={result.severity} sx={{ mt: 2 }} onClose={() => setResult(null)}>
{result.message} {result.message}
</Alert> </Alert>
)} )}

View File

@@ -9,9 +9,8 @@ import {
Chip, Chip,
CircularProgress, CircularProgress,
Alert, Alert,
Snackbar, Divider,
} 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";
@@ -25,6 +24,8 @@ 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",
@@ -67,6 +68,20 @@ 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();
@@ -75,7 +90,7 @@ export default function FetchRequestDetail() {
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 [retrying, setRetrying] = useState(false);
const [failNotif, setFailNotif] = useState<string | null>(null); 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({
@@ -109,7 +124,7 @@ export default function FetchRequestDetail() {
refetchRequest(); refetchRequest();
} }
if (parsed.status === "failed") { if (parsed.status === "failed") {
setFailNotif(parsed.message.error || "Fetch request failed"); showToast(parsed.message.error || "Fetch request failed", "error");
refetchRequest(); refetchRequest();
} }
if (parsed.status === "completed" || parsed.step === "resume_extract") { if (parsed.status === "completed" || parsed.step === "resume_extract") {
@@ -153,7 +168,7 @@ export default function FetchRequestDetail() {
await patch(id, { status: "pending" }); await patch(id, { status: "pending" });
refetchRequest(); refetchRequest();
} catch (err: any) { } catch (err: any) {
setFailNotif(formatApiError(err)); showToast(formatApiError(err), "error");
} finally { } finally {
setRetrying(false); setRetrying(false);
} }
@@ -162,7 +177,7 @@ export default function FetchRequestDetail() {
const req = fetchRequest as any; const req = fetchRequest as any;
const retryCount = req?.retry_count ?? 0; const retryCount = req?.retry_count ?? 0;
const isRetryExhausted = retryCount >= RETRY_MAX; const isRetryExhausted = retryCount >= RETRY_MAX;
const status = req?.status as FetchRequestStatus | undefined; const status = (req?.status ?? "pending") as FetchRequestStatus;
const detailFields = (resource?.orderedFields ?? []).filter( const detailFields = (resource?.orderedFields ?? []).filter(
(f) => f.name !== "source", (f) => f.name !== "source",
); );
@@ -193,34 +208,44 @@ export default function FetchRequestDetail() {
if (fetchError || !fetchRequest) { if (fetchError || !fetchRequest) {
return ( return (
<Container sx={{ mt: 4 }}> <Container sx={{ mt: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}> <PageHeader
Back crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests", path: "/fetch-requests" }, { label: "Details" }]}
</Button> title="Fetch Request"
/>
<Alert severity="error">Failed to load fetch request</Alert> <Alert severity="error">Failed to load fetch request</Alert>
</Container> </Container>
); );
} }
return ( return (
<Container sx={{ mt: 4, mb: 4 }}> <Container maxWidth="lg" sx={{ py: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}> <PageHeader
Back to Fetch Requests crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests", path: "/fetch-requests" }, { label: displayTitle }]}
</Button> title={displayTitle}
subtitle="Live pipeline status, progress events, and ambiguity resolution for this import."
/>
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined"> <Section
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}> title="Overview"
action={
<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}>{displayTitle}</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 }}>
@@ -242,6 +267,8 @@ export default function FetchRequestDetail() {
})} })}
</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">
@@ -260,7 +287,7 @@ export default function FetchRequestDetail() {
</Button> </Button>
)} )}
</Box> </Box>
</Paper> </Section>
{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 }}>
@@ -280,33 +307,33 @@ export default function FetchRequestDetail() {
liveParsedCount={liveParsedCount ?? 0} liveParsedCount={liveParsedCount ?? 0}
/> />
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined"> <Section
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}> title="Progress Events"
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}> action={
Progress Events <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
</Typography> <Box
<Box sx={{
sx={{ width: 8,
width: 10, height: 8,
height: 10, borderRadius: "50%",
borderRadius: "50%", bgcolor: sseConnected ? "success.main" : "error.main",
bgcolor: sseConnected ? "success.main" : "error.main", }}
flexShrink: 0, />
}} <Typography variant="caption" color="text.secondary">
/> {sseConnected ? "Connected" : "Disconnected"}
<Typography variant="caption" color="text.secondary"> </Typography>
{sseConnected ? "Connected" : "Disconnected"} </Box>
</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 ? (
@@ -321,7 +348,7 @@ export default function FetchRequestDetail() {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 1.5, gap: 1.5,
p: 1, p: 1.25,
borderRadius: 2, borderRadius: 2,
bgcolor: "action.hover", bgcolor: "action.hover",
}} }}
@@ -341,20 +368,9 @@ export default function FetchRequestDetail() {
)) ))
)} )}
</Box> </Box>
</Paper> </Section>
<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

@@ -1,52 +1,36 @@
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 (
<AppBar <Box
position="fixed" component="footer"
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',
}} }}
> >
<Toolbar> <Container
<React.Fragment> maxWidth="lg"
<Container sx={{
sx={{ display: 'flex',
display: 'flex', alignItems: 'center',
flexDirection: 'column', justifyContent: 'space-between',
alignItems: 'center', gap: 2,
gap: { xs: 2, sm: 4 }, flexWrap: 'wrap',
textAlign: { sm: 'center', md: 'left' }, }}
}} >
> <Typography variant="body2" color="text.secondary" fontWeight={500}>
<Copyright /> Khata
</Container> </Typography>
</React.Fragment> <Typography variant="caption" color="text.secondary">
</Toolbar> © {new Date().getFullYear()} Aetoskia Internal Infrastructure — All rights reserved.
</AppBar> </Typography>
</Container>
</Box>
); );
} }

View File

@@ -1,8 +1,5 @@
import * as React from "react"; import * as React from "react";
import { import { useLocation, matchPath, useNavigate } from "react-router-dom";
useLocation,
matchPath
} from "react-router-dom";
import { import {
AppBar, AppBar,
Toolbar, Toolbar,
@@ -11,6 +8,11 @@ import {
Tooltip, Tooltip,
Button, Button,
Box, Box,
Drawer,
List,
ListItemButton,
ListItemText,
Divider,
useMediaQuery, useMediaQuery,
useTheme, useTheme,
} from "@mui/material"; } from "@mui/material";
@@ -18,7 +20,6 @@ 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";
@@ -30,134 +31,260 @@ interface HeaderProps {
onDrawerToggle?: () => void; onDrawerToggle?: () => void;
} }
export default function Header({ const NAV_LINKS = [
routerMapping, { label: "Home", path: "/" },
onDrawerToggle, { label: "Expenses", path: "/expenses" },
}: HeaderProps) { { label: "Fetch Requests", path: "/fetch-requests" },
const location = useLocation(); ];
const matchedRoute = routerMapping.find((route) =>
matchPath({ path: route.path, end: false }, location.pathname)
);
const headerTitle = matchedRoute?.headerTitle ?? "Khata";
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 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;
return ( const navContent = (
<AppBar <Box
position="fixed"
color="default"
sx={{ sx={{
zIndex: (theme) => theme.zIndex.drawer + 1, display: { xs: "none", md: "flex" },
backdropFilter: "blur(8px)", alignItems: "center",
boxShadow: "none", gap: 0.5,
borderBottom: "1px solid", ml: 3,
borderColor: "divider",
}} }}
> >
<Toolbar> {NAV_LINKS.map((link) => {
{/* MOBILE MENU BUTTON */} const active = isActive(link.path, location.pathname);
{isMobile && onDrawerToggle && ( return (
<IconButton <Button
key={link.path}
color="inherit" color="inherit"
edge="start" onClick={() => navigate(link.path)}
onClick={onDrawerToggle} sx={{
sx={{ mr: 2 }} textTransform: "none",
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" },
}}
> >
<MenuIcon /> {link.label}
</IconButton> </Button>
)} );
})}
</Box>
);
{/* THEME TOGGLE */} const authSection = (
<IconButton onClick={toggleColorMode} color="inherit" sx={{ mr: 2 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />} {isAuthenticated ? (
</IconButton> <>
{/* TITLE */}
<Typography
variant="h6"
noWrap
sx={{ fontWeight: "bold", cursor: "pointer" }}
onClick={() => navigate("/")}
>
{headerTitle}
</Typography>
<span style={{ flexGrow: 1 }} />
{/* NAV LINKS */}
<Box
sx={{
display: { xs: "none", md: "flex" },
alignItems: "center",
mr: 2,
gap: 1,
}}
>
{[
{ label: "Fetch", path: "/fetch-requests" },
].map(({ label, path }) => (
<Button
key={path}
color="inherit"
onClick={() => navigate(path)}
sx={{ textTransform: "none", fontWeight: 500, px: 1.5 }}
size="small"
>
{label}
</Button>
))}
</Box>
{/* AUTH SECTION */}
{isAuthenticated ? (
<>
<Box
sx={{
display: { xs: "none", sm: "flex" },
alignItems: "center",
mr: 2,
}}
>
<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>
</>
) : (
<Button <Button
color="inherit" color="inherit"
variant="outlined" onClick={() => navigate("/admin")}
onClick={() => navigate("/login")} sx={{
sx={{ textTransform: "none" }} textTransform: "none",
fontWeight: isActive("/admin", location.pathname) ? 600 : 500,
color: isActive("/admin", location.pathname) ? "text.primary" : "text.secondary",
display: { xs: "none", sm: "inline-flex" },
}}
> >
Login Admin
</Button> </Button>
)} <Button
</Toolbar> color="inherit"
</AppBar> 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
</Button>
)}
</Box>
); );
}
const drawerContent = (
<Box sx={{ width: 260 }} role="presentation">
<Box sx={{ p: 2 }}>
<Brand />
</Box>
<Divider />
<List>
{routerMapping
.filter((r) => !r.path.startsWith("/login") && !r.path.startsWith("/register") && !r.path.startsWith("/profile"))
.map((route) => {
const active = isActive(route.path, location.pathname);
return (
<ListItemButton
key={route.path}
selected={active}
onClick={() => {
navigate(route.path);
setMobileOpen(false);
}}
sx={{
borderRadius: "6px",
mx: 1,
mb: 0.5,
"&.Mui-selected": {
backgroundColor: "action.selected",
},
}}
>
<ListItemText
primary={route.headerTitle}
primaryTypographyProps={{ fontWeight: active ? 600 : 500, fontSize: 14 }}
/>
</ListItemButton>
);
})}
<Divider sx={{ my: 1 }} />
{isAuthenticated ? (
<>
<ListItemButton onClick={() => navigate("/profile/me")} sx={{ borderRadius: "6px", mx: 1 }}>
<ListItemText primary="Profile" primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
<ListItemButton onClick={logout} sx={{ borderRadius: "6px", mx: 1 }}>
<ListItemText primary="Logout" primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
</>
) : (
<ListItemButton onClick={() => navigate("/login")} sx={{ borderRadius: "6px", mx: 1 }}>
<ListItemText primary="Login" primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
)}
</List>
</Box>
);
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, Chip } from "@mui/material"; import { Box, Typography, Button, Container, Grid, Paper, alpha } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/Dashboard"; import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import SyncIcon from "@mui/icons-material/Sync"; import SyncIcon from "@mui/icons-material/Sync";
import BarChartIcon from "@mui/icons-material/BarChart"; import AccountBalanceWalletIcon from "@mui/icons-material/AccountBalanceWallet";
import SettingsIcon from "@mui/icons-material/Settings"; import AccountCircleIcon from "@mui/icons-material/AccountCircle";
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,11 +14,9 @@ interface FeatureCardProps {
title: string; title: string;
description: string; description: string;
path: string; path: string;
label?: string;
accent: string;
} }
function FeatureCard({ icon, title, description, path, label, accent }: FeatureCardProps) { function FeatureCard({ icon, title, description, path }: FeatureCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme(); const theme = useTheme();
@@ -35,206 +33,154 @@ function FeatureCard({ icon, title, description, path, label, accent }: FeatureC
height: "100%", height: "100%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
position: "relative", transition: "border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease",
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",
"&::before": { opacity: 1 }, transform: "translateY(-2px)",
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 sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1.5 }}> <Box
<Box sx={{
sx={{ width: 40,
width: 40, height: 40,
height: 40, borderRadius: 2,
borderRadius: 2, display: "flex",
display: "flex", alignItems: "center",
alignItems: "center", justifyContent: "center",
justifyContent: "center", backgroundColor: "action.hover",
background: alpha(accent, 0.12), color: "primary.main",
color: accent, mb: 2,
}} }}
> >
{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>
{label && ( <Box
<Chip className="feature-arrow"
label={label} sx={{
size="small" mt: 2,
variant="outlined" display: "flex",
sx={{ mt: 2, alignSelf: "flex-start", textTransform: "capitalize" }} alignItems: "center",
/> 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: <DashboardIcon />, icon: <ReceiptLongIcon />,
title: "Dashboard", title: "Expenses",
description: "Visualise inflows and outflows with interactive charts, drill into categories, and track trends over daily, weekly, and monthly periods.", description:
path: "/dashboard", "Browse every transaction imported from your accounts — grouped by month, with entity, account, tags, and amount at a glance.",
accent: theme.palette.mode === "dark" ? "#818cf8" : "#6366f1", path: "/expenses",
}, },
{ {
icon: <SyncIcon />, icon: <SyncIcon />,
title: "Fetch Requests", title: "Fetch Requests",
description: "Upload bank statements or configure email ingestion to auto-import transactions. Track pipeline status from pending through to completion.", description:
"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: <BarChartIcon />, icon: <AccountBalanceWalletIcon />,
title: "Report Snapshots", title: "Accounts & Entities",
description: "Generate cached report snapshots with custom filters — accounts, date ranges, amount bounds — then pin a snapshot on the dashboard for consistent comparisons.", description:
path: "/reports", "Manage your accounts, expenses, tags, and payors through the OpenAPI-driven admin panel with full CRUD and server-side filters.",
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 <Box sx={{ width: "100%" }}>
sx={{ <Container maxWidth="lg" sx={{ py: { xs: 6, md: 10 } }}>
minHeight: "calc(100vh - 64px)", <Box sx={{ maxWidth: 680, mx: { xs: "auto", md: 0 }, textAlign: { xs: "center", md: "left" } }}>
display: "flex", <Typography
flexDirection: "column", variant="overline"
position: "relative", sx={{ color: "primary.main", mb: 1.5, display: "block", fontWeight: 700 }}
overflow: "hidden", >
"&::before": { Khata · Financial Ledger
content: '""', </Typography>
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={{
fontWeight: 800, color: "text.primary",
fontSize: { xs: "2.5rem", sm: "3.5rem", md: "5rem" }, fontSize: { xs: "2.25rem", md: "3.25rem" },
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 50%, #f59e0b 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: "-0.03em", letterSpacing: "-0.03em",
mb: 2, mb: 2,
}} }}
> >
Welcome to Khata Your intelligent, extensible financial ledger.
</Typography> </Typography>
<Typography <Typography
variant="h6" variant="body1"
color="text.secondary" color="text.secondary"
sx={{ sx={{ maxWidth: 540, lineHeight: 1.7, fontSize: "1.0625rem" }}
maxWidth: 580,
mx: "auto",
lineHeight: 1.7,
fontWeight: 400,
fontSize: { xs: "1rem", md: "1.15rem" },
}}
> >
Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow. Import transactions from bank statements, enrich them with entity and tag intelligence,
and stay on top of your cashflow — all in one place.
</Typography> </Typography>
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: "center", flexWrap: "wrap" }}> <Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: { xs: "center", md: "flex-start" }, flexWrap: "wrap" }}>
<Button <Button
variant="contained" variant="contained"
size="large" size="large"
endIcon={<ArrowForwardIcon />} endIcon={<ArrowForwardIcon />}
onClick={() => navigate("/dashboard")} onClick={() => navigate(currentUser ? "/fetch-requests" : "/login")}
sx={{ sx={{ px: 3.5, py: 1.4, fontWeight: 600 }}
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)}`,
},
}}
> >
Enter Dashboard {currentUser ? "Enter App" : "Get Started"}
</Button> </Button>
<Button <Button
variant="outlined" variant="outlined"
size="large" size="large"
onClick={() => navigate("/fetch-requests")} onClick={() => navigate("/fetch-requests")}
sx={{ sx={{ px: 3.5, py: 1.4, fontWeight: 600 }}
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}> <Grid container spacing={3} sx={{ mt: { xs: 4, md: 8 } }}>
{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

@@ -5,22 +5,25 @@ 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, 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/Expense';
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();
@@ -76,6 +79,7 @@ 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: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" },
{ path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" }, { path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" },
]; ];
@@ -84,6 +88,16 @@ const routerMapping = [
function AppContent() { function AppContent() {
const { authConfig, loading } = useAppContext(); const { authConfig, loading } = 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) { if (loading) {
return ( return (
@@ -97,23 +111,37 @@ function AppContent() {
<AuthProvider authConfig={authConfig} onUnauthorized={() => navigate("/login")}> <AuthProvider authConfig={authConfig} onUnauthorized={() => navigate("/login")}>
<AppTheme> <AppTheme>
<CssBaseline enableColorScheme /> <CssBaseline enableColorScheme />
<Header routerMapping={routerMapping} /> <ToastProvider>
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
<Header routerMapping={routerMapping} />
<Box sx={{ pb: 8 }}> <Box
<Toolbar /> component="main"
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>
<Routes> <Footer />
{routerMapping.map(({ path, component: Component }) => ( </Box>
<Route </ToastProvider>
key={path}
path={path}
element={<Component basePath={path.replace(/\/\*$/, "")} />}
/>
))}
</Routes>
</Box>
<Footer />
</AppTheme> </AppTheme>
</AuthProvider> </AuthProvider>
); );

View File

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

View File

@@ -1,21 +1,14 @@
import { Theme, alpha, Components } from '@mui/material/styles'; import { Theme, alpha, Components } from '@mui/material/styles';
import { gray, orange } from '../themePrimitives'; import { gray } 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: 10, borderRadius: (theme.vars || theme).shape.borderRadius,
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(orange[900], 0.35), backgroundColor: alpha((theme.vars || theme).palette.background.paper, 0.6),
border: `1px solid ${alpha(orange[800], 0.3)}`,
}), }),
}), }),
}, },
@@ -24,7 +17,7 @@ export const feedbackCustomizations: Components<Theme> = {
styleOverrides: { styleOverrides: {
root: ({ theme }) => ({ root: ({ theme }) => ({
'& .MuiDialog-paper': { '& .MuiDialog-paper': {
borderRadius: '10px', borderRadius: (theme.vars || theme).shape.borderRadius,
border: '1px solid', border: '1px solid',
borderColor: (theme.vars || theme).palette.divider, borderColor: (theme.vars || theme).palette.divider,
}, },

View File

@@ -58,31 +58,26 @@ export const inputsCustomizations: Components<Theme> = {
}, },
style: { style: {
color: 'white', color: 'white',
backgroundColor: gray[900], backgroundColor: brand[500],
backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`, border: `1px solid ${brand[600]}`,
boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`, boxShadow: 'none',
border: `1px solid ${gray[700]}`,
'&:hover': { '&:hover': {
backgroundImage: 'none', backgroundColor: brand[600],
backgroundColor: gray[700],
boxShadow: 'none', boxShadow: 'none',
}, },
'&:active': { '&:active': {
backgroundColor: gray[800], backgroundColor: brand[700],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
color: 'black', color: brand[50],
backgroundColor: gray[50], backgroundColor: brand[500],
backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`, border: `1px solid ${brand[600]}`,
boxShadow: 'inset 0 -1px 0 hsl(220, 30%, 80%)',
border: `1px solid ${gray[50]}`,
'&:hover': { '&:hover': {
backgroundImage: 'none', backgroundColor: brand[400],
backgroundColor: gray[300],
boxShadow: 'none', boxShadow: 'none',
}, },
'&:active': { '&:active': {
backgroundColor: gray[400], backgroundColor: brand[300],
}, },
}), }),
}, },
@@ -94,18 +89,28 @@ export const inputsCustomizations: Components<Theme> = {
}, },
style: { style: {
color: 'white', color: 'white',
backgroundColor: brand[300], backgroundColor: gray[900],
backgroundImage: `linear-gradient(to bottom, ${alpha(brand[400], 0.8)}, ${brand[500]})`, border: `1px solid ${gray[700]}`,
boxShadow: `inset 0 2px 0 ${alpha(brand[200], 0.2)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`, boxShadow: 'none',
border: `1px solid ${brand[500]}`,
'&:hover': { '&:hover': {
backgroundColor: brand[700], backgroundColor: gray[800],
boxShadow: 'none', boxShadow: 'none',
}, },
'&:active': { '&:active': {
backgroundColor: brand[700], backgroundColor: gray[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: 8, borderRadius: (theme.vars || theme).shape.borderRadius,
'&: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,32 +35,37 @@ 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(210, 100%, 95%)', 50: 'hsl(250, 100%, 98%)',
100: 'hsl(210, 100%, 92%)', 100: 'hsl(250, 95%, 95%)',
200: 'hsl(210, 100%, 80%)', 200: 'hsl(249, 92%, 90%)',
300: 'hsl(210, 100%, 65%)', 300: 'hsl(248, 92%, 80%)',
400: 'hsl(210, 98%, 48%)', 400: 'hsl(245, 90%, 72%)',
500: 'hsl(210, 98%, 42%)', 500: 'hsl(243, 76%, 59%)',
600: 'hsl(210, 98%, 55%)', 600: 'hsl(243, 75%, 51%)',
700: 'hsl(210, 100%, 35%)', 700: 'hsl(243, 78%, 42%)',
800: 'hsl(210, 100%, 16%)', 800: 'hsl(242, 76%, 30%)',
900: 'hsl(210, 100%, 21%)', 900: 'hsl(241, 75%, 20%)',
}; };
/** 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%, 88%)', 200: 'hsl(220, 20%, 89%)',
300: 'hsl(220, 20%, 80%)', 300: 'hsl(220, 17%, 80%)',
400: 'hsl(220, 20%, 65%)', 400: 'hsl(220, 15%, 64%)',
500: 'hsl(220, 20%, 42%)', 500: 'hsl(220, 14%, 45%)',
600: 'hsl(220, 20%, 35%)', 600: 'hsl(220, 13%, 36%)',
700: 'hsl(220, 20%, 25%)', 700: 'hsl(220, 13%, 27%)',
750: 'hsl(220, 20%, 18%)', 750: 'hsl(220, 13%, 20%)',
800: 'hsl(220, 30%, 6%)', 800: 'hsl(220, 12%, 12%)',
850: 'hsl(220, 22%, 11%)', 850: 'hsl(220, 10%, 8%)',
900: 'hsl(220, 35%, 3%)', 900: 'hsl(220, 9%, 6%)',
}; };
export const green = { export const green = {
@@ -116,27 +121,39 @@ export const getDesignTokens = (mode: PaletteMode) => {
palette: { palette: {
mode, mode,
primary: { primary: {
light: brand[200], light: brand[400],
main: brand[400], main: brand[500],
dark: brand[700], dark: brand[700],
contrastText: brand[50], contrastText: brand[50],
...(mode === 'dark' && { ...(mode === 'dark' && {
contrastText: brand[50], contrastText: brand[50],
light: 'hsl(210, 50%, 65%)', light: 'hsl(245, 85%, 70%)',
main: 'hsl(210, 55%, 55%)', main: 'hsl(243, 76%, 64%)',
dark: 'hsl(210, 50%, 35%)', dark: 'hsl(243, 78%, 48%)',
}),
},
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[300], main: brand[400],
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(210, 40%, 50%)', light: 'hsl(245, 60%, 60%)',
main: 'hsl(210, 35%, 40%)', main: 'hsl(243, 55%, 52%)',
dark: 'hsl(210, 30%, 25%)', dark: 'hsl(243, 50%, 38%)',
}), }),
}, },
warning: { warning: {
@@ -174,7 +191,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%, 99%)', default: 'hsl(0, 0%, 100%)',
paper: 'hsl(220, 35%, 97%)', paper: 'hsl(220, 35%, 97%)',
...(mode === 'dark' && { default: darkBg, paper: darkPaper }), ...(mode === 'dark' && { default: darkBg, paper: darkPaper }),
}, },
@@ -208,242 +225,73 @@ export const getDesignTokens = (mode: PaletteMode) => {
typography: { typography: {
fontFamily: 'Inter, sans-serif', fontFamily: 'Inter, sans-serif',
h1: { h1: {
fontSize: defaultTheme.typography.pxToRem(48), fontSize: defaultTheme.typography.pxToRem(40),
fontWeight: 600, fontWeight: 600,
lineHeight: 1.2, lineHeight: 1.1,
letterSpacing: -0.5, letterSpacing: '-0.025em',
}, },
h2: { h2: {
fontSize: defaultTheme.typography.pxToRem(36), fontSize: defaultTheme.typography.pxToRem(32),
fontWeight: 600, fontWeight: 600,
lineHeight: 1.2, lineHeight: 1.2,
letterSpacing: '-0.02em',
}, },
h3: { h3: {
fontSize: defaultTheme.typography.pxToRem(30), fontSize: defaultTheme.typography.pxToRem(26),
lineHeight: 1.2, fontWeight: 600,
lineHeight: 1.25,
letterSpacing: '-0.015em',
}, },
h4: { h4: {
fontSize: defaultTheme.typography.pxToRem(24), fontSize: defaultTheme.typography.pxToRem(20),
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(18), fontSize: defaultTheme.typography.pxToRem(16),
fontWeight: 500,
}, },
subtitle2: { subtitle2: {
fontSize: defaultTheme.typography.pxToRem(14), fontSize: defaultTheme.typography.pxToRem(13),
fontWeight: 500, fontWeight: 500,
}, },
body1: { body1: {
fontSize: defaultTheme.typography.pxToRem(14), fontSize: defaultTheme.typography.pxToRem(15),
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: 8, borderRadius: 6,
}, },
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;

57
src/ui/EmptyState.tsx Normal file
View File

@@ -0,0 +1,57 @@
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>
);
}

67
src/ui/PageHeader.tsx Normal file
View File

@@ -0,0 +1,67 @@
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>
);
}

52
src/ui/Toast.tsx Normal file
View File

@@ -0,0 +1,52 @@
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;
}