## 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>
245 lines
7.8 KiB
TypeScript
245 lines
7.8 KiB
TypeScript
import * as React from 'react';
|
|
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";
|
|
|
|
export interface AuthPageProps {
|
|
mode: AuthMode;
|
|
onBack(): void;
|
|
onSwitchMode(): void;
|
|
login(username: string, password: string): Promise<void>;
|
|
register(username: string, password: string): Promise<void>;
|
|
loading: boolean;
|
|
error: string | null;
|
|
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,
|
|
onSwitchMode,
|
|
login,
|
|
register,
|
|
loading,
|
|
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";
|
|
|
|
React.useEffect(() => {
|
|
if (currentUser) onBack();
|
|
}, [currentUser, onBack]);
|
|
|
|
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={700} gutterBottom sx={{ letterSpacing: '-0.02em' }}>
|
|
{isLogin ? "Sign in to Khata" : "Create your account"}
|
|
</Typography>
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
|
{isLogin
|
|
? "Welcome back. Enter your credentials to continue."
|
|
: "Start managing your ledger in a few seconds."}
|
|
</Typography>
|
|
|
|
<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: 0.5 }}>
|
|
{error}
|
|
</Typography>
|
|
)}
|
|
|
|
<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={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>
|
|
);
|
|
}
|