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
This commit is contained in:
2026-08-17 16:27:43 +05:30
parent d6856a538f
commit 3dd833ac2b
13 changed files with 200 additions and 191 deletions

View File

@@ -5,13 +5,11 @@ import {
Accordion,
AccordionSummary,
AccordionDetails,
Avatar,
Chip,
useTheme,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import type { ExpenseItem } from "./types";
import { formatCurrency, formatDate, isExpense, monthKey, monthLabel } from "./types";
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 {
@@ -41,40 +39,15 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] {
.sort((a, b) => b.key.localeCompare(a.key));
}
function EntityAvatar({ entity }: { entity: ExpenseItem["entity"] }) {
const theme = useTheme();
const name = entity?.name ?? "?";
const letter = name.trim().charAt(0).toUpperCase() || "?";
const logo = entity?.logo;
const isImage = typeof logo === "string" && (logo.startsWith("http") || logo.startsWith("data:"));
return (
<Avatar
src={isImage ? logo : undefined}
sx={{
width: 36,
height: 36,
fontSize: 15,
fontWeight: 700,
bgcolor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
borderRadius: 2,
}}
>
{letter}
</Avatar>
);
}
interface ExpenseCardProps {
item: ExpenseItem;
expanded: boolean;
currency: string;
fields: ExpenseFieldConfigs;
onToggle: (id: string) => void;
}
const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, onToggle }: ExpenseCardProps) {
const negative = isExpense(item);
const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, fields, onToggle }: ExpenseCardProps) {
const itemCurrency = item.account?.currency ?? currency;
return (
@@ -105,7 +78,21 @@ const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency,
},
}}
>
<EntityAvatar entity={item.entity} />
<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"
@@ -113,43 +100,34 @@ const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency,
noWrap
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
>
{item.entity?.name ?? "Unknown"}
{item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{formatDate(item.occurred_at)}
</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 && (
<Chip
size="small"
label={item.account.name}
variant="outlined"
sx={{ height: 20, fontSize: 11, "& .MuiChip-label": { px: 1 } }}
<ListCellRenderer
field={fields.account}
value={item.account}
displayFormat={fields.formats.account}
/>
)}
</Box>
</Box>
<Typography
variant="body1"
fontWeight={700}
sx={{
fontSize: "0.9375rem",
fontVariantNumeric: "tabular-nums",
color: negative ? "error.main" : "success.main",
flexShrink: 0,
}}
>
{formatCurrency(item.amount, itemCurrency)}
</Typography>
<CurrencyField value={item.amount} currency={itemCurrency} large />
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<ExpenseDetail item={item} />
<ExpenseDetail item={item} fields={fields} />
</AccordionDetails>
</Accordion>
);
});
export function ExpenseList({ items }: { items: ExpenseItem[] }) {
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) => {
@@ -180,6 +158,7 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
item={item}
expanded={expandedId === item.id}
currency={group.currency}
fields={fields}
onToggle={handleToggle}
/>
))}
@@ -190,4 +169,4 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
);
}
export { groupByMonth };
export { groupByMonth };