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,6 +5,7 @@ export { useAppContext } from "./src/context/AppContext";
export { useResource } from "./src/context/useResource";
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField";
export { SseStreamView } from "./src/components/SseStreamView";
export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
export { getApi } from "./src/hooks/useApi";

View File

@@ -2,6 +2,7 @@ import React from "react";
import { Box, Typography, Avatar } from "@mui/material";
import type { FieldConfig } from "../../types";
import { ListCellRenderer } from "./ListCellRenderer";
import { CurrencyField } from "./renderers/CurrencyField";
interface DetailFieldProps {
field: FieldConfig;
@@ -18,7 +19,9 @@ export function DetailFieldRenderer({ field, value, displayFormat, basePath }: D
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
{field.label}
</Typography>
{field.uiType === "image" ? (
{field.uiType === "currency" ? (
<CurrencyField value={Number(value)} large />
) : field.uiType === "image" ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />

View File

@@ -96,6 +96,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
return <BooleanField field={field} value={value} onChange={onChange} />;
}
if (field.uiType === "currency") {
return (
<NumberField
field={field}
value={value}
onChange={onChange}
error={error}
/>
);
}
if (field.type === "integer" || field.type === "number") {
return (
<NumberField

View File

@@ -4,6 +4,7 @@ import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, Dial
import type { FieldConfig } from "../../types";
import { applyDisplayFormat } from "./utils";
import { InlineRefField } from "./renderers/InlineRefField";
import { CurrencyField } from "./renderers/CurrencyField";
import { extractFields } from "../../transformers/field-config";
import { useAppContext } from "../../context/AppContext";
@@ -140,6 +141,10 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List
return <Avatar src={value} variant="rounded" sx={{ width: 40, height: 40 }} />;
}
if (field.uiType === "currency" && value != null && !Number.isNaN(Number(value))) {
return <CurrencyField value={Number(value)} />;
}
if (field.type === "boolean") {
return <Chip label={value ? "Yes" : "No"} size="small" color={value ? "success" : "default"} />;
}

View File

@@ -3,3 +3,4 @@ export { ListCellRenderer } from "./ListCellRenderer";
export { DetailFieldRenderer } from "./DetailFieldRenderer";
export { applyDisplayFormat } from "./utils";
export { JsonField } from "./renderers/JsonField";
export { CurrencyField, formatCurrency } from "./renderers/CurrencyField";

View File

@@ -0,0 +1,42 @@
import React from "react";
import { Typography } from "@mui/material";
const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"];
const _currencyFormatters = new Map<string, Intl.NumberFormat>();
export function formatCurrency(amount: number, currency?: string): string {
const code = currency && CURRENCIES.includes(currency) ? currency : "INR";
let formatter = _currencyFormatters.get(code);
if (!formatter) {
formatter = new Intl.NumberFormat("en-IN", {
style: "currency",
currency: code,
maximumFractionDigits: 2,
});
_currencyFormatters.set(code, formatter);
}
return formatter.format(amount);
}
interface CurrencyFieldProps {
value: number;
currency?: string;
large?: boolean;
}
export function CurrencyField({ value, currency, large }: CurrencyFieldProps) {
const negative = value < 0;
return (
<Typography
component="span"
sx={{
fontWeight: 700,
fontSize: large ? "0.9375rem" : "0.8125rem",
fontVariantNumeric: "tabular-nums",
color: negative ? "error.main" : "success.main",
}}
>
{formatCurrency(value, currency)}
</Typography>
);
}

View File

@@ -43,6 +43,20 @@ function extractProfileOperations(spec: any): ProfileOperation[] {
return ops;
}
function applyResourceOverrides(configs: ResourceConfig[], specConfiguration: SpecConfiguration): ResourceConfig[] {
for (const resource of configs) {
const fieldTypes = specConfiguration.resourceConfig?.[resource.name]?.fieldTypes;
if (!fieldTypes) continue;
// fields and orderedFields share the same FieldConfig object references,
// so an in-place mutation updates both.
for (const field of resource.fields) {
const override = fieldTypes[field.name];
if (override) field.uiType = override;
}
}
return configs;
}
const DEFAULT_AUTH_CONFIG: AuthConfig = {
serverUrl: "",
loginPath: "/login",
@@ -88,7 +102,7 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
if (errs.length === 0) {
const configs = buildResourceConfigs(spec);
if (!cancelled) {
setResources(configs);
setResources(applyResourceOverrides(configs, specConfiguration));
}
const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? "";

View File

@@ -4,6 +4,8 @@ export interface ResourceConfiguration {
filterOptions?: {
mode?: FilterMode;
};
/** Map of field name → uiType override (e.g. { amount: "currency" }). */
fieldTypes?: Record<string, string>;
}
export interface ProfileComponents {

View File

@@ -10,11 +10,13 @@ import {
} from "@mui/material";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import { useNavigate } from "react-router-dom";
import { useResource } from "../../react-openapi";
import { useResource, useAppContext, formatCurrency } from "../../react-openapi";
import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState";
import { ExpenseList } from "./ExpenseList";
import { ExpenseItem, formatCurrency, isExpense, monthKey, monthLabel } from "./types";
import { ExpenseItem, ExpenseFieldConfigs, isExpense, monthKey, monthLabel, resolveLogoUrl } from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL;
function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
return (
@@ -39,13 +41,50 @@ function StatCard({ label, value, hint }: { label: string; value: string; hint?:
export default function Expense() {
const navigate = useNavigate();
const { list, loading, error } = useResource("expenses");
const { list, loading, error, resource } = useResource("expenses");
const { resources } = useAppContext();
const [items, setItems] = useState<ExpenseItem[] | null>(null);
const fieldConfigs = useMemo<ExpenseFieldConfigs | null>(() => {
if (!resource) return null;
const find = (name: string) => resource.fields.find((f) => f.name === name);
const entity = find("entity");
const amount = find("amount");
const account = find("account");
const tags = find("tags");
const occurredAt = find("occurred_at");
const entitiesRes = resources.find((r) => r.name === "entities");
const accountsRes = resources.find((r) => r.name === "accounts");
const tagsRes = resources.find((r) => r.name === "tags");
const logo = entitiesRes?.fields.find((f) => f.name === "logo");
if (!entity || !amount || !account || !tags || !occurredAt || !logo) return null;
return {
entity,
amount,
account,
tags,
occurredAt,
logo,
formats: {
entity: entitiesRes?.displayFormat ?? "{name}",
account: accountsRes?.displayFormat ?? "{name}",
tags: tagsRes?.displayFormat ?? "{name}",
},
};
}, [resource, resources]);
useEffect(() => {
let mounted = true;
list({ limit: 0 }).then((res) => {
if (mounted) setItems(res.items as ExpenseItem[]);
if (!mounted) return;
const rows = (res.items ?? []) as ExpenseItem[];
const normalized = rows.map((it) => ({
...it,
entity: it.entity
? { ...it.entity, logo: resolveLogoUrl(it.entity.logo, API_BASE) }
: it.entity,
}));
setItems(normalized);
});
return () => {
mounted = false;
@@ -132,7 +171,7 @@ export default function Expense() {
<StatCard label="Income" value={formatCurrency(summary.totalIncome, summary.currency)} />
</Box>
<ExpenseList items={sorted} />
{fieldConfigs && <ExpenseList items={sorted} fields={fieldConfigs} />}
</>
)}
</Container>

View File

@@ -1,87 +1,24 @@
import React from "react";
import { Box, Typography, Chip, Divider } from "@mui/material";
import type { ExpenseItem } from "./types";
import { formatCurrency, formatDate, formatDateTime } from "./types";
import { Box, Divider } from "@mui/material";
import { DetailFieldRenderer } from "../../react-openapi";
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Box sx={{ display: "flex", gap: 2, alignItems: "baseline", py: 0.75 }}>
<Typography variant="body2" color="text.secondary" sx={{ width: 160, flexShrink: 0 }}>
{label}
</Typography>
<Box sx={{ flex: 1, minWidth: 0 }}>{value}</Box>
</Box>
);
interface ExpenseDetailProps {
item: ExpenseItem;
fields: ExpenseFieldConfigs;
}
export function ExpenseDetail({ item }: { item: ExpenseItem }) {
const account = item.account;
const tags = item.tags ?? [];
const last4 = account?.number ? `${account.number.slice(-4)}` : "";
export function ExpenseDetail({ item, fields }: ExpenseDetailProps) {
return (
<Box sx={{ pt: 1, pb: 0.5 }}>
<Divider sx={{ mb: 1.5 }} />
<Row
label="Amount"
value={
<Typography variant="body2" fontWeight={600}>
{formatCurrency(item.amount, account?.currency)}
</Typography>
}
/>
<Row
label="Account"
value={
<Box sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1 }}>
<Typography component="span" variant="body2">
{account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"}
</Typography>
{account?.type && (
<Chip
size="small"
label={account.type.replace(/_/g, " ")}
variant="outlined"
sx={{ fontSize: 11, height: 20 }}
/>
)}
</Box>
}
/>
<Row
label="Tags"
value={
tags.length > 0 ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap" }}>
{tags.map((tag, i) => (
<Chip
key={`${tag.name}-${i}`}
size="small"
label={`${tag.icon ?? ""} ${tag.name ?? ""}`.trim()}
sx={{ fontSize: 12, height: 24 }}
/>
))}
</Box>
) : (
<Typography variant="body2" color="text.disabled">
No tags
</Typography>
)
}
/>
<Row label="Date" value={<Typography variant="body2">{formatDate(item.occurred_at)}</Typography>} />
<Row
label="Transaction ID"
value={
<Typography variant="body2" sx={{ fontFamily: "monospace", fontSize: "0.8125rem" }}>
{item.id}
</Typography>
}
/>
<Row
label="Created"
value={<Typography variant="body2" color="text.secondary">{formatDateTime(item.created_at)}</Typography>}
/>
<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,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}
/>
))}

View File

@@ -1,3 +1,5 @@
import type { FieldConfig } from "../../react-openapi";
export interface ExpenseItem {
id: string;
entity?: { name?: string; type?: string; logo?: string } | null;
@@ -9,57 +11,29 @@ export interface ExpenseItem {
updated_at?: string;
}
export interface ExpenseFieldConfigs {
entity: FieldConfig;
amount: FieldConfig;
account: FieldConfig;
tags: FieldConfig;
occurredAt: FieldConfig;
logo: FieldConfig;
formats: {
entity: string;
account: string;
tags: string;
};
}
export function isExpense(item: ExpenseItem): boolean {
return (item.amount ?? 0) < 0;
}
const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"];
const _currencyFormatters = new Map<string, Intl.NumberFormat>();
export function formatCurrency(amount: number, currency?: string): string {
const code = currency && CURRENCIES.includes(currency) ? currency : "INR";
let formatter = _currencyFormatters.get(code);
if (!formatter) {
formatter = new Intl.NumberFormat("en-IN", {
style: "currency",
currency: code,
maximumFractionDigits: 2,
});
_currencyFormatters.set(code, formatter);
}
return formatter.format(amount);
}
const _dateCache = new Map<string, string>();
export function formatDate(iso?: string): string {
if (!iso) return "—";
const cached = _dateCache.get(iso);
if (cached) return cached;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const out = d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
_dateCache.set(iso, out);
return out;
}
const _dateTimeCache = new Map<string, string>();
export function formatDateTime(iso?: string): string {
if (!iso) return "—";
const cached = _dateTimeCache.get(iso);
if (cached) return cached;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const out = d.toLocaleString("en-IN", {
day: "numeric",
month: "short",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
_dateTimeCache.set(iso, out);
return out;
export function resolveLogoUrl(logo?: string, base?: string): string | undefined {
if (!logo) return undefined;
if (logo.startsWith("http") || logo.startsWith("data:")) return logo;
if (logo.startsWith("/") && base) return `${base.replace(/\/+$/, "")}${logo}`;
return logo;
}
export function monthKey(iso?: string): string {

View File

@@ -11,6 +11,7 @@ export const specConfiguration: SpecConfiguration = {
resourceConfig: {
expenses: {
filterOptions: { mode: "client" },
fieldTypes: { amount: "currency" },
},
},
};