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:
357
src/Header.tsx
357
src/Header.tsx
@@ -1,8 +1,5 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
useLocation,
|
||||
matchPath
|
||||
} from "react-router-dom";
|
||||
import { useLocation, matchPath, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
@@ -11,6 +8,11 @@ import {
|
||||
Tooltip,
|
||||
Button,
|
||||
Box,
|
||||
Drawer,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Divider,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
@@ -18,7 +20,6 @@ import MenuIcon from "@mui/icons-material/Menu";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
||||
import LightModeIcon from "@mui/icons-material/LightMode";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../react-auth";
|
||||
import { ColorModeContext } from "./shared-theme/AppTheme";
|
||||
|
||||
@@ -30,134 +31,260 @@ interface HeaderProps {
|
||||
onDrawerToggle?: () => void;
|
||||
}
|
||||
|
||||
export default function Header({
|
||||
routerMapping,
|
||||
onDrawerToggle,
|
||||
}: HeaderProps) {
|
||||
const location = useLocation();
|
||||
const matchedRoute = routerMapping.find((route) =>
|
||||
matchPath({ path: route.path, end: false }, location.pathname)
|
||||
);
|
||||
const headerTitle = matchedRoute?.headerTitle ?? "Khata";
|
||||
const NAV_LINKS = [
|
||||
{ label: "Home", path: "/" },
|
||||
{ label: "Expenses", path: "/expenses" },
|
||||
{ label: "Fetch Requests", path: "/fetch-requests" },
|
||||
];
|
||||
|
||||
function isActive(path: string, locationPath: string): boolean {
|
||||
if (path === "/") return locationPath === "/" || locationPath === "/home";
|
||||
return matchPath({ path, end: false }, locationPath) !== null;
|
||||
}
|
||||
|
||||
export default function Header({ routerMapping, onDrawerToggle }: HeaderProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { currentUser, logout } = useAuth();
|
||||
const { mode, toggleColorMode } = React.useContext(ColorModeContext);
|
||||
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
const isAuthenticated = !!currentUser;
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
color="default"
|
||||
const navContent = (
|
||||
<Box
|
||||
sx={{
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
backdropFilter: "blur(8px)",
|
||||
boxShadow: "none",
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
display: { xs: "none", md: "flex" },
|
||||
alignItems: "center",
|
||||
gap: 0.5,
|
||||
ml: 3,
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
{/* MOBILE MENU BUTTON */}
|
||||
{isMobile && onDrawerToggle && (
|
||||
<IconButton
|
||||
{NAV_LINKS.map((link) => {
|
||||
const active = isActive(link.path, location.pathname);
|
||||
return (
|
||||
<Button
|
||||
key={link.path}
|
||||
color="inherit"
|
||||
edge="start"
|
||||
onClick={onDrawerToggle}
|
||||
sx={{ mr: 2 }}
|
||||
onClick={() => navigate(link.path)}
|
||||
sx={{
|
||||
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 />
|
||||
</IconButton>
|
||||
)}
|
||||
{link.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
|
||||
{/* THEME TOGGLE */}
|
||||
<IconButton onClick={toggleColorMode} color="inherit" sx={{ mr: 2 }}>
|
||||
{mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
const authSection = (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Button
|
||||
color="inherit"
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/login")}
|
||||
sx={{ textTransform: "none" }}
|
||||
onClick={() => navigate("/admin")}
|
||||
sx={{
|
||||
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>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={() => navigate("/profile/me")}
|
||||
sx={{ textTransform: "none", fontWeight: 500 }}
|
||||
>
|
||||
{currentUser.username}
|
||||
</Button>
|
||||
<Tooltip title="Logout">
|
||||
<IconButton color="inherit" onClick={logout} aria-label="Logout">
|
||||
<LogoutIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
color="inherit"
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/login")}
|
||||
sx={{ textTransform: "none" }}
|
||||
>
|
||||
Login
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user