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:
173
src/Expense/ExpenseList.tsx
Normal file
173
src/Expense/ExpenseList.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user