- add CurrencyField renderer (inherits NumberField for editing) with cached Intl formatter; route via uiType "currency" in list/detail/form - add resourceConfig.fieldTypes override applied in AppProvider, so amount becomes currency for both Admin and the Expenses page - render Expenses page through react-openapi fields (ListCellRenderer, DetailFieldRenderer, applyDisplayFormat) instead of custom fields - resolve relative /uploads entity logos against the API base URL on load so entity logos render on the expenses feed - drop custom avatar, currency/date formatters from the Expense module
172 lines
5.5 KiB
TypeScript
172 lines
5.5 KiB
TypeScript
import React, { useCallback, useMemo, useState } from "react";
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Accordion,
|
|
AccordionSummary,
|
|
AccordionDetails,
|
|
} from "@mui/material";
|
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
|
|
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
|
|
import { monthKey, monthLabel } from "./types";
|
|
import { ExpenseDetail } from "./ExpenseDetail";
|
|
|
|
interface GroupedMonth {
|
|
key: string;
|
|
items: ExpenseItem[];
|
|
total: number;
|
|
currency: string;
|
|
}
|
|
|
|
function groupByMonth(items: ExpenseItem[]): GroupedMonth[] {
|
|
const map = new Map<string, ExpenseItem[]>();
|
|
for (const item of items) {
|
|
const key = monthKey(item.occurred_at);
|
|
const list = map.get(key) ?? [];
|
|
list.push(item);
|
|
map.set(key, list);
|
|
}
|
|
return [...map.entries()]
|
|
.map(([key, list]) => {
|
|
const sorted = [...list].sort(
|
|
(a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(),
|
|
);
|
|
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
|
|
const total = sorted.reduce((sum, it) => sum + (it.amount ?? 0), 0);
|
|
return { key, items: sorted, total, currency };
|
|
})
|
|
.sort((a, b) => b.key.localeCompare(a.key));
|
|
}
|
|
|
|
interface ExpenseCardProps {
|
|
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 itemCurrency = item.account?.currency ?? currency;
|
|
|
|
return (
|
|
<Accordion
|
|
disableGutters
|
|
expanded={expanded}
|
|
onChange={(_, isExpanded) => onToggle(isExpanded ? item.id : "")}
|
|
TransitionProps={{ unmountOnExit: true }}
|
|
sx={{
|
|
border: "1px solid",
|
|
borderColor: expanded ? "primary.main" : "divider",
|
|
borderRadius: 2,
|
|
overflow: "hidden",
|
|
boxShadow: "none",
|
|
"&:before": { display: "none" },
|
|
transition: "border-color 160ms ease, background-color 160ms ease",
|
|
"&:hover": { borderColor: "primary.light" },
|
|
}}
|
|
>
|
|
<AccordionSummary
|
|
expandIcon={<ExpandMoreIcon />}
|
|
sx={{
|
|
"& .MuiAccordionSummary-content": {
|
|
alignItems: "center",
|
|
gap: 2,
|
|
minWidth: 0,
|
|
py: 0.5,
|
|
},
|
|
}}
|
|
>
|
|
<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: 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>
|
|
);
|
|
});
|
|
|
|
interface ExpenseListProps {
|
|
items: ExpenseItem[];
|
|
fields: ExpenseFieldConfigs;
|
|
}
|
|
|
|
export function ExpenseList({ items, fields }: ExpenseListProps) {
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const groups = useMemo(() => groupByMonth(items), [items]);
|
|
const handleToggle = useCallback((id: string) => {
|
|
setExpandedId((prev) => (prev === id ? null : id));
|
|
}, []);
|
|
|
|
return (
|
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
{groups.map((group) => (
|
|
<Box key={group.key}>
|
|
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1.5, mb: 1.5 }}>
|
|
<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={600}>
|
|
{formatCurrency(group.total, group.currency)}
|
|
</Typography>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
export { groupByMonth }; |