3 Commits

Author SHA1 Message Date
806bd42673 dropped amount sign from CurrencyField.tsx 2026-08-18 19:15:55 +05:30
62dd06c226 expense list: month accordions + month-year selector; drop unused x-data-grid
- make each month a card-sized accordion (single-open, first month
  expanded by default) instead of a sticky header over flat rows
- turn the floating pill into a month-year selector: opens a menu of
  all months and expands + scrolls to the chosen month
- remove tags from expense rows again until a placement is settled
- remove unused @mui/x-data-grid@7, which pulled @mui/system@7 over
  material's v5 and caused the createTheme runtime crash
2026-08-18 15:24:26 +05:30
8e9a13dd19 expense list: replace accordion with flat Stripe-style rows
- drop the per-row accordion and delete ExpenseDetail (was only used
  for the expanded view); rows are now static bordered cards with a
  hover highlight
- row layout: logo | name/date + account chip | amount
- tag chips were added to the row during iteration but removed for
  now until a better placement is settled
2026-08-18 13:44:06 +05:30
7 changed files with 166 additions and 413 deletions

265
package-lock.json generated
View File

@@ -13,7 +13,6 @@
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7",
"@mui/x-data-grid": "^7.22.0",
"@tanstack/react-query": "^5.96.1",
"axios": "^1.7.7",
"buffer": "^6.0.3",
@@ -1097,201 +1096,6 @@
}
}
},
"node_modules/@mui/private-theming": {
"version": "7.3.11",
"resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz",
"integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/utils": "^7.3.11",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/private-theming/node_modules/@mui/types": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
"integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/private-theming/node_modules/@mui/utils": {
"version": "7.3.11",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
"integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/types": "^7.4.12",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.3"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/styled-engine": {
"version": "7.3.10",
"resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz",
"integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0",
"csstype": "^3.2.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/@mui/system": {
"version": "7.3.11",
"resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz",
"integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/private-theming": "^7.3.11",
"@mui/styled-engine": "^7.3.10",
"@mui/types": "^7.4.12",
"@mui/utils": "^7.3.11",
"clsx": "^2.1.1",
"csstype": "^3.2.3",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/system/node_modules/@mui/types": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz",
"integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/system/node_modules/@mui/utils": {
"version": "7.3.11",
"resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz",
"integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.6",
"@mui/types": "^7.4.12",
"@types/prop-types": "^15.7.15",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"react-is": "^19.2.3"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@mui/types": {
"version": "7.2.24",
"resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz",
@@ -1334,62 +1138,6 @@
}
}
},
"node_modules/@mui/x-data-grid": {
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.29.13.tgz",
"integrity": "sha512-XHrZTvpa61eqSgUIevDzXYfYCbI7cbN/aRxSCaw2cZzQZ/USdpECYbnTEhgw5XgUlvkAK0mItKZmgKM4X7zT+Q==",
"dependencies": {
"@babel/runtime": "^7.25.7",
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0",
"@mui/x-internals": "7.29.0",
"clsx": "^2.1.1",
"prop-types": "^15.8.1",
"reselect": "^5.1.1",
"use-sync-external-store": "^1.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0",
"@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
}
},
"node_modules/@mui/x-internals": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz",
"integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==",
"dependencies": {
"@babel/runtime": "^7.25.7",
"@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0"
},
"engines": {
"node": ">=14.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -4174,11 +3922,6 @@
"node": ">=0.10.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -4516,14 +4259,6 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",

View File

@@ -13,7 +13,6 @@
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5.16.7",
"@mui/material": "^5.16.7",
"@mui/x-data-grid": "^7.22.0",
"@tanstack/react-query": "^5.96.1",
"axios": "^1.7.7",
"buffer": "^6.0.3",

View File

@@ -14,7 +14,7 @@ Auto-generates an admin panel (CRUD, datatable, relationship management) from an
```bash
npm install react-openapi
# peer dependencies (install these in your app):
npm install react react-dom react-router-dom @mui/material @mui/icons-material @mui/x-data-grid @emotion/react @emotion/styled axios js-yaml
npm install react react-dom react-router-dom @mui/material @mui/icons-material @emotion/react @emotion/styled axios js-yaml
```
## Quick Start

View File

@@ -11,7 +11,6 @@
"react-router-dom": "^6.28.0",
"@mui/material": "^5.16.7",
"@mui/icons-material": "^5.16.7",
"@mui/x-data-grid": "^7.22.0",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"axios": "^1.7.7",

View File

@@ -15,7 +15,7 @@ export function formatCurrency(amount: number, currency?: string): string {
});
_currencyFormatters.set(code, formatter);
}
return formatter.format(amount);
return formatter.format(Math.abs(amount));
}
interface CurrencyFieldProps {

View File

@@ -1,24 +0,0 @@
import React from "react";
import { Box, Divider } from "@mui/material";
import { DetailFieldRenderer } from "../../react-openapi";
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
interface ExpenseDetailProps {
item: ExpenseItem;
fields: ExpenseFieldConfigs;
}
export function ExpenseDetail({ item, fields }: ExpenseDetailProps) {
return (
<Box sx={{ pt: 1, pb: 0.5 }}>
<Divider sx={{ mb: 1.5 }} />
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<DetailFieldRenderer field={fields.entity} value={item.entity} displayFormat={fields.formats.entity} />
<DetailFieldRenderer field={fields.amount} value={item.amount} />
<DetailFieldRenderer field={fields.account} value={item.account} displayFormat={fields.formats.account} />
<DetailFieldRenderer field={fields.tags} value={item.tags} displayFormat={fields.formats.tags} />
<DetailFieldRenderer field={fields.occurredAt} value={item.occurred_at} />
</Box>
</Box>
);
}

View File

@@ -5,14 +5,16 @@ import {
Accordion,
AccordionSummary,
AccordionDetails,
Menu,
MenuItem,
ListItemText,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types";
import { ExpenseDetail } from "./ExpenseDetail";
interface GroupedMonth {
key: string;
@@ -43,86 +45,74 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] {
.sort((a, b) => b.key.localeCompare(a.key));
}
interface ExpenseCardProps {
interface ExpenseRowProps {
item: ExpenseItem;
expanded: boolean;
currency: string;
fields: ExpenseFieldConfigs;
onToggle: (id: string) => void;
}
const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, fields, onToggle }: ExpenseCardProps) {
const ExpenseRow = React.memo(function ExpenseRow({ item, currency, fields }: ExpenseRowProps) {
const itemCurrency = item.account?.currency ?? currency;
return (
<Accordion
disableGutters
expanded={expanded}
onChange={(_, isExpanded) => onToggle(isExpanded ? item.id : "")}
TransitionProps={{ unmountOnExit: true }}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
px: 2,
py: 1.25,
border: "1px solid",
borderColor: expanded ? "primary.main" : "divider",
borderColor: "divider",
borderRadius: 2,
overflow: "hidden",
boxShadow: "none",
"&:before": { display: "none" },
transition: "border-color 160ms ease, background-color 160ms ease",
"&:hover": { borderColor: "primary.light" },
backgroundColor: "background.paper",
transition: "background-color 160ms ease, border-color 160ms ease",
"&:hover": {
backgroundColor: "action.hover",
borderColor: "primary.light",
},
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
<Box
sx={{
"& .MuiAccordionSummary-content": {
alignItems: "center",
gap: 2,
minWidth: 0,
py: 0.5,
},
width: 40,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Box
sx={{
width: 40,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
{item.entity?.logo ? (
<ListCellRenderer field={fields.logo} value={item.entity.logo} />
) : (
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: "action.hover" }} />
)}
</Box>
<Box sx={{ flex: "0 1 auto", minWidth: 0 }}>
<Typography
variant="body1"
fontWeight={600}
noWrap
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
>
{item.entity?.logo ? (
<ListCellRenderer field={fields.logo} value={item.entity.logo} />
) : (
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: "action.hover" }} />
{item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box sx={{ color: "text.secondary" }}>
<ListCellRenderer field={fields.occurredAt} value={item.occurred_at} />
</Box>
{item.account?.name && (
<ListCellRenderer
field={fields.account}
value={item.account}
displayFormat={fields.formats.account}
/>
)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body1"
fontWeight={600}
noWrap
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
>
{item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 0.25, flexWrap: "wrap" }}>
<ListCellRenderer field={fields.occurredAt} value={item.occurred_at} />
{item.account?.name && (
<ListCellRenderer
field={fields.account}
value={item.account}
displayFormat={fields.formats.account}
/>
)}
</Box>
</Box>
<CurrencyField value={item.amount} currency={itemCurrency} large />
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<ExpenseDetail item={item} fields={fields} />
</AccordionDetails>
</Accordion>
</Box>
<Box sx={{ flex: 1 }} />
<CurrencyField value={item.amount} currency={itemCurrency} large />
</Box>
);
});
@@ -132,35 +122,52 @@ interface ExpenseListProps {
}
export function ExpenseList({ items, fields }: ExpenseListProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
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 handleToggle = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
}, []);
const pillRef = useRef<HTMLDivElement>(null);
const didInitOpenMonth = useRef(false);
const jumpToActiveMonth = useCallback(() => {
if (!activeMonth) return;
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="${activeMonth}"]`,
`[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" });
}, [activeMonth]);
}, []);
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();
jumpToActiveMonth();
if (pillRef.current) setMenuAnchor(pillRef.current);
}
},
[jumpToActiveMonth],
[],
);
const selectedMonth = activeMonth ?? openMonth ?? groups[0]?.key ?? null;
useEffect(() => {
const root = listRef.current;
if (!root || groups.length === 0) return;
@@ -198,59 +205,72 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
return (
<>
<Box ref={listRef} sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
<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 }} />
<Box
<Accordion
disableGutters
data-month-header={group.key}
expanded={openMonth === group.key}
onChange={(_, isExpanded) => setOpenMonth(isExpanded ? group.key : null)}
TransitionProps={{ unmountOnExit: true }}
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",
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" },
}}
>
<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
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 }}>
{group.items.map((item) => (
<ExpenseRow
key={item.id}
item={item}
currency={group.currency}
fields={fields}
/>
))}
</Box>
</AccordionDetails>
</Accordion>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{group.items.map((item) => (
<ExpenseCard
key={item.id}
item={item}
expanded={expandedId === item.id}
currency={group.currency}
fields={fields}
onToggle={handleToggle}
/>
))}
</Box>
</Box>
))}
))}
</Box>
<Box
@@ -266,10 +286,13 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
}}
>
<Box
ref={pillRef}
role="button"
tabIndex={0}
onClick={jumpToActiveMonth}
onClick={(e) => setMenuAnchor(e.currentTarget)}
onKeyDown={handlePillKeyDown}
aria-haspopup="menu"
aria-expanded={Boolean(menuAnchor)}
sx={{
display: "flex",
alignItems: "center",
@@ -295,12 +318,33 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
},
}}
>
<KeyboardArrowUpIcon sx={{ fontSize: 14, color: "text.secondary" }} />
<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>
</>
);
}