Files
khata-ui/src/common/components/TransactionList.tsx
Vishesh 'ironeagle' Bangotra a107029b90 feat: group transactions by date inside month accordions
Add two-layer grouping to the shared transaction list: each month
accordion now renders one card per occurred_at date (most recent first)
with a date label and txn count header, nesting the usual transaction
rows inside. Add dateLabel + groupByDate helpers to src/common and use
them in TransactionList. Month accordions, scroll pill/menu, and month
totals are unchanged.
2026-08-20 14:14:44 +05:30

287 lines
9.9 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
Menu,
MenuItem,
ListItemText,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { formatCurrency } from "../../../react-openapi";
import type { ExpenseItem, TxnFieldConfigs } from "../types";
import { groupByDate, groupByMonth } from "../utils/transactions";
import { monthLabel } from "../utils/dates";
import { TransactionRow } from "./TransactionRow";
interface TransactionListProps {
items: ExpenseItem[];
fields: TxnFieldConfigs;
}
export function TransactionList({ items, fields }: TransactionListProps) {
const [activeMonth, setActiveMonth] = useState<string | null>(null);
const [openMonth, setOpenMonth] = useState<string | null>(null);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
const groups = useMemo(() => groupByMonth(items), [items]);
const listRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const didInitOpenMonth = useRef(false);
useEffect(() => {
if (!didInitOpenMonth.current && groups.length > 0) {
didInitOpenMonth.current = true;
setOpenMonth(groups[0].key);
}
}, [groups]);
const scrollToMonth = useCallback((key: string) => {
const anchor = listRef.current?.querySelector<HTMLElement>(
`[data-month-anchor="${key}"]`,
);
if (!anchor) return;
const offset = window.matchMedia("(min-width: 900px)").matches ? 64 : 56;
const top = anchor.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: "smooth" });
}, []);
const handleSelectMonth = useCallback(
(key: string) => {
setMenuAnchor(null);
setOpenMonth(key);
scrollToMonth(key);
},
[scrollToMonth],
);
const handlePillKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (pillRef.current) setMenuAnchor(pillRef.current);
}
},
[],
);
const selectedMonth = activeMonth ?? openMonth ?? groups[0]?.key ?? null;
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 (
<>
<Box ref={listRef} sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
{groups.map((group) => (
<Box key={group.key}>
<span data-month-anchor={group.key} aria-hidden="true" style={{ display: "block", height: 0 }} />
<Accordion
disableGutters
data-month-header={group.key}
expanded={openMonth === group.key}
onChange={(_, isExpanded) => setOpenMonth(isExpanded ? group.key : null)}
TransitionProps={{ unmountOnExit: true }}
sx={{
border: "1px solid",
borderColor: openMonth === group.key ? "primary.main" : "divider",
borderRadius: 2,
overflow: "hidden",
boxShadow: "none",
backgroundColor: "background.paper",
"&:before": { display: "none" },
transition: "border-color 160ms ease, background-color 160ms ease",
"&:hover": { borderColor: "primary.light" },
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
px: 2,
py: 1,
"& .MuiAccordionSummary-content": {
alignItems: "center",
gap: 1.5,
minWidth: 0,
},
}}
>
<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={700} color="error.main">
{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>
</AccordionSummary>
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{groupByDate(group.items).map((dateGroup) => (
<Box
key={dateGroup.date}
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
backgroundColor: "background.default",
p: 1,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 1,
pb: 1,
borderBottom: "1px solid",
borderColor: "divider",
mb: 1,
}}
>
<Typography variant="subtitle2" fontWeight={600} sx={{ letterSpacing: "-0.01em" }}>
{dateGroup.label}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="caption" color="text.secondary">
{dateGroup.items.length} transaction{dateGroup.items.length === 1 ? "" : "s"}
</Typography>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{dateGroup.items.map((item) => (
<TransactionRow
key={item.id}
item={item}
currency={group.currency}
fields={fields}
/>
))}
</Box>
</Box>
))}
</Box>
</AccordionDetails>
</Accordion>
</Box>
))}
</Box>
<Box
sx={{
position: "fixed",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: 3,
pointerEvents: activeMonth ? "auto" : "none",
opacity: activeMonth ? 1 : 0,
transition: "opacity 160ms ease",
}}
>
<Box
ref={pillRef}
role="button"
tabIndex={0}
onClick={(e) => setMenuAnchor(e.currentTarget)}
onKeyDown={handlePillKeyDown}
aria-haspopup="menu"
aria-expanded={Boolean(menuAnchor)}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1.5,
py: 0.75,
borderRadius: "999px",
cursor: "pointer",
userSelect: "none",
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85),
backdropFilter: "blur(8px)",
border: "1px solid",
borderColor: "divider",
boxShadow: 1,
transition: "background-color 160ms ease, transform 160ms ease",
"&:hover": {
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.95),
transform: "translateY(-1px)",
},
"&:focus-visible": {
outline: "2px solid",
outlineColor: "primary.main",
},
}}
>
<Typography variant="caption" fontWeight={700} color="text.secondary">
{activeMonth ? monthLabel(activeMonth) : ""}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: "text.secondary" }} />
</Box>
</Box>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={() => setMenuAnchor(null)}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
transformOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{groups.map((group) => (
<MenuItem
key={group.key}
selected={selectedMonth === group.key}
onClick={() => handleSelectMonth(group.key)}
>
<ListItemText
primary={monthLabel(group.key)}
secondary={`${group.items.length} transactions`}
/>
</MenuItem>
))}
</Menu>
</>
);
}