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.
287 lines
9.9 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
} |