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
This commit is contained in:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import { Box, Typography } from "@mui/material";
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Accordion,
|
|
||||||
AccordionSummary,
|
|
||||||
AccordionDetails,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { alpha } from "@mui/material/styles";
|
import { alpha } from "@mui/material/styles";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
||||||
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
|
||||||
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 { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types";
|
import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types";
|
||||||
import { ExpenseDetail } from "./ExpenseDetail";
|
|
||||||
|
|
||||||
interface GroupedMonth {
|
interface GroupedMonth {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -43,86 +35,74 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] {
|
|||||||
.sort((a, b) => b.key.localeCompare(a.key));
|
.sort((a, b) => b.key.localeCompare(a.key));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExpenseCardProps {
|
interface ExpenseRowProps {
|
||||||
item: ExpenseItem;
|
item: ExpenseItem;
|
||||||
expanded: boolean;
|
|
||||||
currency: string;
|
currency: string;
|
||||||
fields: ExpenseFieldConfigs;
|
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;
|
const itemCurrency = item.account?.currency ?? currency;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Box
|
||||||
disableGutters
|
|
||||||
expanded={expanded}
|
|
||||||
onChange={(_, isExpanded) => onToggle(isExpanded ? item.id : "")}
|
|
||||||
TransitionProps={{ unmountOnExit: true }}
|
|
||||||
sx={{
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 2,
|
||||||
|
px: 2,
|
||||||
|
py: 1.25,
|
||||||
border: "1px solid",
|
border: "1px solid",
|
||||||
borderColor: expanded ? "primary.main" : "divider",
|
borderColor: "divider",
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: "hidden",
|
backgroundColor: "background.paper",
|
||||||
boxShadow: "none",
|
transition: "background-color 160ms ease, border-color 160ms ease",
|
||||||
"&:before": { display: "none" },
|
"&:hover": {
|
||||||
transition: "border-color 160ms ease, background-color 160ms ease",
|
backgroundColor: "action.hover",
|
||||||
"&:hover": { borderColor: "primary.light" },
|
borderColor: "primary.light",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AccordionSummary
|
<Box
|
||||||
expandIcon={<ExpandMoreIcon />}
|
|
||||||
sx={{
|
sx={{
|
||||||
"& .MuiAccordionSummary-content": {
|
width: 40,
|
||||||
alignItems: "center",
|
flexShrink: 0,
|
||||||
gap: 2,
|
display: "flex",
|
||||||
minWidth: 0,
|
alignItems: "center",
|
||||||
py: 0.5,
|
justifyContent: "center",
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
{item.entity?.logo ? (
|
||||||
sx={{
|
<ListCellRenderer field={fields.logo} value={item.entity.logo} />
|
||||||
width: 40,
|
) : (
|
||||||
flexShrink: 0,
|
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: "action.hover" }} />
|
||||||
display: "flex",
|
)}
|
||||||
alignItems: "center",
|
</Box>
|
||||||
justifyContent: "center",
|
<Box sx={{ flex: "0 1 auto", minWidth: 0 }}>
|
||||||
}}
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
fontWeight={600}
|
||||||
|
noWrap
|
||||||
|
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
|
||||||
>
|
>
|
||||||
{item.entity?.logo ? (
|
{item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"}
|
||||||
<ListCellRenderer field={fields.logo} value={item.entity.logo} />
|
</Typography>
|
||||||
) : (
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||||
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: "action.hover" }} />
|
<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>
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
</Box>
|
||||||
<Typography
|
<Box sx={{ flex: 1 }} />
|
||||||
variant="body1"
|
<CurrencyField value={item.amount} currency={itemCurrency} large />
|
||||||
fontWeight={600}
|
</Box>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,13 +112,9 @@ interface ExpenseListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ExpenseList({ items, fields }: ExpenseListProps) {
|
export function ExpenseList({ items, fields }: ExpenseListProps) {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
||||||
const [activeMonth, setActiveMonth] = 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 listRef = useRef<HTMLDivElement>(null);
|
||||||
const handleToggle = useCallback((id: string) => {
|
|
||||||
setExpandedId((prev) => (prev === id ? null : id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const jumpToActiveMonth = useCallback(() => {
|
const jumpToActiveMonth = useCallback(() => {
|
||||||
if (!activeMonth) return;
|
if (!activeMonth) return;
|
||||||
@@ -239,13 +215,11 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
|
|||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<ExpenseCard
|
<ExpenseRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
expanded={expandedId === item.id}
|
|
||||||
currency={group.currency}
|
currency={group.currency}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
onToggle={handleToggle}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user