expense list: split month totals, sticky headers + month pill
- show spent and income totals separately per month header (debits red, credits green) instead of a single net amount - pin the current month header below the navbar while scrolling its section (glass backdrop, divider), Stripe-style grouping - add a floating month pill that updates via scroll-spy so the active month stays visible across long feeds
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -6,16 +6,18 @@ import {
|
|||||||
AccordionSummary,
|
AccordionSummary,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
|
import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
|
||||||
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
|
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
|
||||||
import { monthKey, monthLabel, parseOccurredAt } from "./types";
|
import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types";
|
||||||
import { ExpenseDetail } from "./ExpenseDetail";
|
import { ExpenseDetail } from "./ExpenseDetail";
|
||||||
|
|
||||||
interface GroupedMonth {
|
interface GroupedMonth {
|
||||||
key: string;
|
key: string;
|
||||||
items: ExpenseItem[];
|
items: ExpenseItem[];
|
||||||
total: number;
|
spent: number;
|
||||||
|
income: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,8 +35,9 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] {
|
|||||||
(a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
|
(a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
|
||||||
);
|
);
|
||||||
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
|
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
|
||||||
const total = sorted.reduce((sum, it) => sum + (it.amount ?? 0), 0);
|
const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0);
|
||||||
return { key, items: sorted, total, currency };
|
const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0);
|
||||||
|
return { key, items: sorted, spent, income, currency };
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.key.localeCompare(a.key));
|
.sort((a, b) => b.key.localeCompare(a.key));
|
||||||
}
|
}
|
||||||
@@ -129,16 +132,70 @@ interface ExpenseListProps {
|
|||||||
|
|
||||||
export function ExpenseList({ items, fields }: ExpenseListProps) {
|
export function ExpenseList({ items, fields }: ExpenseListProps) {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [activeMonth, setActiveMonth] = useState<string | null>(null);
|
||||||
const groups = useMemo(() => groupByMonth(items), [items]);
|
const groups = useMemo(() => groupByMonth(items), [items]);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const handleToggle = useCallback((id: string) => {
|
const handleToggle = useCallback((id: string) => {
|
||||||
setExpandedId((prev) => (prev === id ? null : id));
|
setExpandedId((prev) => (prev === id ? null : id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = listRef.current;
|
||||||
|
if (!root || groups.length === 0) return;
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
ticking = false;
|
||||||
|
const headers = root.querySelectorAll<HTMLElement>("[data-month-header]");
|
||||||
|
let current: string | null = null;
|
||||||
|
for (const header of headers) {
|
||||||
|
if (header.getBoundingClientRect().top <= 72) {
|
||||||
|
current = header.dataset.monthHeader ?? null;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setActiveMonth(current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!ticking) {
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
window.addEventListener("resize", onScroll);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
window.removeEventListener("resize", onScroll);
|
||||||
|
};
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<>
|
||||||
{groups.map((group) => (
|
<Box ref={listRef} sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
<Box key={group.key}>
|
{groups.map((group) => (
|
||||||
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1.5, mb: 1.5 }}>
|
<Box key={group.key}>
|
||||||
|
<Box
|
||||||
|
data-month-header={group.key}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 1.5,
|
||||||
|
mb: 1.5,
|
||||||
|
position: "sticky",
|
||||||
|
top: { xs: 56, md: 64 },
|
||||||
|
zIndex: 2,
|
||||||
|
py: 0.5,
|
||||||
|
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85),
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
borderBottom: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
|
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
|
||||||
{monthLabel(group.key)}
|
{monthLabel(group.key)}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -146,8 +203,14 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
|
|||||||
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
|
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ flex: 1 }} />
|
<Box sx={{ flex: 1 }} />
|
||||||
<Typography variant="body2" fontWeight={600}>
|
<Typography variant="body2" fontWeight={700} color="error.main">
|
||||||
{formatCurrency(group.total, group.currency)}
|
{formatCurrency(group.spent, group.currency)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.disabled">
|
||||||
|
/
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={700} color="success.main">
|
||||||
|
{formatCurrency(group.income, group.currency)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -165,7 +228,40 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: 16,
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
zIndex: 3,
|
||||||
|
pointerEvents: "none",
|
||||||
|
opacity: activeMonth ? 1 : 0,
|
||||||
|
transition: "opacity 160ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
px: 1.5,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: "999px",
|
||||||
|
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85),
|
||||||
|
backdropFilter: "blur(8px)",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "divider",
|
||||||
|
boxShadow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" fontWeight={700} color="text.secondary">
|
||||||
|
{activeMonth ? monthLabel(activeMonth) : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user