design overhaul + expenses page

- Stripe-style theme: single indigo (#635BFF) brand, 6px radius,
  Inter scale; rewritten themePrimitives + MUI customizations
  (solid primary, near-black secondary, severity-aware Alert)
- Sticky Header (brand-left nav, theme toggle, mobile drawer),
  inline Footer, route fade-in, per-route document.title
- Home hero + feature cards (dead /dashboard,/reports links replaced)
- Split-panel AuthPage with validation and password visibility
- Data pages: PageHeader/EmptyState, breadcrumbed Fetch Request
  pages, admin table polish (numeric alignment, row-hover actions,
  skeletons, empty states), single-accent admin SideMenu
- Global ToastProvider wired into app shell
- New /expenses page: month-grouped feed, single-open accordion
  with inline detail (account, tags, amount, timestamps)
This commit is contained in:
2026-08-17 15:14:57 +05:30
parent 51762f8d18
commit 9808a1f39b
22 changed files with 1567 additions and 790 deletions

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

@@ -0,0 +1,173 @@
import React, { useState } from "react";
import {
Box,
Paper,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
Avatar,
Chip,
useTheme,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { ExpenseItem } from "./types";
import { formatCurrency, formatDate, isExpense, monthKey, monthLabel } from "./types";
import { ExpenseDetail } from "./ExpenseDetail";
interface GroupedMonth {
key: string;
items: ExpenseItem[];
total: 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) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(),
);
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
const total = sorted.reduce((sum, it) => sum + (it.amount ?? 0), 0);
return { key, items: sorted, total, currency };
})
.sort((a, b) => b.key.localeCompare(a.key));
}
function EntityAvatar({ entity }: { entity: ExpenseItem["entity"] }) {
const theme = useTheme();
const name = entity?.name ?? "?";
const letter = name.trim().charAt(0).toUpperCase() || "?";
const logo = entity?.logo;
const isImage = typeof logo === "string" && (logo.startsWith("http") || logo.startsWith("data:"));
return (
<Avatar
src={isImage ? logo : undefined}
sx={{
width: 36,
height: 36,
fontSize: 15,
fontWeight: 700,
bgcolor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
borderRadius: 2,
}}
>
{letter}
</Avatar>
);
}
export function ExpenseList({ items }: { items: ExpenseItem[] }) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const groups = groupByMonth(items);
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
{groups.map((group) => (
<Box key={group.key}>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1.5, mb: 1.5 }}>
<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={600}>
{formatCurrency(group.total, group.currency)}
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{group.items.map((item) => {
const negative = isExpense(item);
const currency = item.account?.currency ?? group.currency;
return (
<Accordion
key={item.id}
disableGutters
expanded={expandedId === item.id}
onChange={(_, expanded) => setExpandedId(expanded ? item.id : null)}
sx={{
border: "1px solid",
borderColor: expandedId === item.id ? "primary.main" : "divider",
borderRadius: 2,
overflow: "hidden",
boxShadow: "none",
"&:before": { display: "none" },
transition: "border-color 160ms ease, background-color 160ms ease",
"&:hover": { borderColor: "primary.light" },
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
"& .MuiAccordionSummary-content": {
alignItems: "center",
gap: 2,
minWidth: 0,
py: 0.5,
},
}}
>
<EntityAvatar entity={item.entity} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body1"
fontWeight={600}
noWrap
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
>
{item.entity?.name ?? "Unknown"}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{formatDate(item.occurred_at)}
</Typography>
{item.account?.name && (
<Chip
size="small"
label={item.account.name}
variant="outlined"
sx={{ height: 20, fontSize: 11, "& .MuiChip-label": { px: 1 } }}
/>
)}
</Box>
</Box>
<Typography
variant="body1"
fontWeight={700}
sx={{
fontSize: "0.9375rem",
fontVariantNumeric: "tabular-nums",
color: negative ? "error.main" : "success.main",
flexShrink: 0,
}}
>
{formatCurrency(item.amount, currency)}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<ExpenseDetail item={item} />
</AccordionDetails>
</Accordion>
);
})}
</Box>
</Box>
))}
</Box>
);
}
export { groupByMonth };