Files
khata-ui/src/Expense/ExpenseList.tsx
Vishesh 'ironeagle' Bangotra 3dd833ac2b shared currency field + expenses rendered from react-openapi fields
- 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
2026-08-17 16:27:43 +05:30

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 };