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) -9808a1fdesign overhaul + expenses page -d6856a5fix expenses list performance + dom nesting -3dd833ashared currency field + expenses rendered from react-openapi fields -e8c585bfix expenses grouping to parse occurred_at as DD-MM-YYYY strictly -cc940eeexpense list: split month totals, sticky headers + month pill -47d799fexpense list: clickable month pill + fix scroll-to-month -537aef9resolve relative media URLs against the API base in react-openapi -8e9a13dexpense list: replace accordion with flat Stripe-style rows -62dd06cexpense list: month accordions + month-year selector; drop unused x-data-grid -806bd42dropped 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:
@@ -1,6 +1,20 @@
|
||||
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 { useTheme } from '@mui/material/styles';
|
||||
|
||||
export type AuthMode = "login" | "register";
|
||||
|
||||
@@ -15,6 +29,12 @@ export interface AuthPageProps {
|
||||
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({
|
||||
mode,
|
||||
onBack,
|
||||
@@ -25,114 +45,200 @@ export function AuthPage({
|
||||
error,
|
||||
currentUser,
|
||||
}: AuthPageProps) {
|
||||
|
||||
const theme = useTheme();
|
||||
const [username, setUsername] = 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 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(() => {
|
||||
if (currentUser) onBack();
|
||||
}, [currentUser, onBack]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 400,
|
||||
mx: 'auto',
|
||||
mt: 8,
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
boxShadow: 3,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
||||
<ArrowBackRoundedIcon />
|
||||
const validate = (): boolean => {
|
||||
const next: { username?: string; password?: string } = {};
|
||||
if (!username.trim()) next.username = "Username is required";
|
||||
else if (username.trim().length < 3) next.username = "Username must be at least 3 characters";
|
||||
if (!password) next.password = "Password is required";
|
||||
else if (password.length < 6) next.password = "Password must be at least 6 characters";
|
||||
setFieldErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
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>
|
||||
|
||||
<Typography variant="h4" fontWeight="bold" gutterBottom>
|
||||
{isLogin ? "Sign In" : "Create Account"}
|
||||
<Typography variant="h4" fontWeight={700} gutterBottom sx={{ letterSpacing: '-0.02em' }}>
|
||||
{isLogin ? "Sign in to Khata" : "Create your account"}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{isLogin
|
||||
? "Please log in to continue"
|
||||
: "Create an account to get started"}
|
||||
? "Welcome back. Enter your credentials to continue."
|
||||
: "Start managing your ledger in a few seconds."}
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
type="username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
if (fieldErrors.username) setFieldErrors((p) => ({ ...p, username: undefined }));
|
||||
}}
|
||||
error={!!fieldErrors.username}
|
||||
helperText={fieldErrors.username}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
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 && (
|
||||
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 3 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : isLogin ? (
|
||||
"Login"
|
||||
) : (
|
||||
"Register"
|
||||
{error && (
|
||||
<Typography color="error" variant="body2" sx={{ mt: 0.5 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
{isLogin ? "Don’t have an account?" : "Already have an account?"}{' '}
|
||||
<Link
|
||||
component="button"
|
||||
underline="hover"
|
||||
color="primary"
|
||||
onClick={onSwitchMode}
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
{isLogin ? "Register" : "Login"}
|
||||
<Typography variant="body2" color="text.secondary" align="center" sx={{ mt: 3 }}>
|
||||
{isLogin ? "Don't have an account?" : "Already have an account?"}{' '}
|
||||
<Link component="button" underline="hover" color="primary" onClick={handleSwitch} sx={{ fontWeight: 600 }}>
|
||||
{isLogin ? "Register" : "Sign In"}
|
||||
</Link>
|
||||
</Typography>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user