From 3dd833ac2beda459914d1aca35c82ae6b5a8148a Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 17 Aug 2026 16:27:43 +0530 Subject: [PATCH] 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 --- react-openapi/index.ts | 1 + .../components/fields/DetailFieldRenderer.tsx | 5 +- .../components/fields/FormFieldRenderer.tsx | 11 +++ .../components/fields/ListCellRenderer.tsx | 5 + react-openapi/src/components/fields/index.ts | 1 + .../fields/renderers/CurrencyField.tsx | 42 ++++++++ react-openapi/src/context/AppProvider.tsx | 16 +++- react-openapi/src/types.ts | 2 + src/Expense/Expense.tsx | 51 ++++++++-- src/Expense/ExpenseDetail.tsx | 91 +++--------------- src/Expense/ExpenseList.tsx | 95 ++++++++----------- src/Expense/types.ts | 70 +++++--------- src/openapi-config.ts | 1 + 13 files changed, 200 insertions(+), 191 deletions(-) create mode 100644 react-openapi/src/components/fields/renderers/CurrencyField.tsx diff --git a/react-openapi/index.ts b/react-openapi/index.ts index 21f743b..0f1a3ac 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -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"; diff --git a/react-openapi/src/components/fields/DetailFieldRenderer.tsx b/react-openapi/src/components/fields/DetailFieldRenderer.tsx index 150e0aa..d21d583 100644 --- a/react-openapi/src/components/fields/DetailFieldRenderer.tsx +++ b/react-openapi/src/components/fields/DetailFieldRenderer.tsx @@ -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 {field.label} - {field.uiType === "image" ? ( + {field.uiType === "currency" ? ( + + ) : field.uiType === "image" ? ( ) : ( diff --git a/react-openapi/src/components/fields/FormFieldRenderer.tsx b/react-openapi/src/components/fields/FormFieldRenderer.tsx index 6d9616e..4b21f66 100644 --- a/react-openapi/src/components/fields/FormFieldRenderer.tsx +++ b/react-openapi/src/components/fields/FormFieldRenderer.tsx @@ -96,6 +96,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk return ; } + if (field.uiType === "currency") { + return ( + + ); + } + if (field.type === "integer" || field.type === "number") { return ( ; } + if (field.uiType === "currency" && value != null && !Number.isNaN(Number(value))) { + return ; + } + if (field.type === "boolean") { return ; } diff --git a/react-openapi/src/components/fields/index.ts b/react-openapi/src/components/fields/index.ts index dcb48b8..279036b 100644 --- a/react-openapi/src/components/fields/index.ts +++ b/react-openapi/src/components/fields/index.ts @@ -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"; diff --git a/react-openapi/src/components/fields/renderers/CurrencyField.tsx b/react-openapi/src/components/fields/renderers/CurrencyField.tsx new file mode 100644 index 0000000..27b748d --- /dev/null +++ b/react-openapi/src/components/fields/renderers/CurrencyField.tsx @@ -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(); + +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 ( + + {formatCurrency(value, currency)} + + ); +} diff --git a/react-openapi/src/context/AppProvider.tsx b/react-openapi/src/context/AppProvider.tsx index 8e7f894..65f3878 100644 --- a/react-openapi/src/context/AppProvider.tsx +++ b/react-openapi/src/context/AppProvider.tsx @@ -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 ?? ""; diff --git a/react-openapi/src/types.ts b/react-openapi/src/types.ts index 0964d1a..2053029 100644 --- a/react-openapi/src/types.ts +++ b/react-openapi/src/types.ts @@ -4,6 +4,8 @@ export interface ResourceConfiguration { filterOptions?: { mode?: FilterMode; }; + /** Map of field name → uiType override (e.g. { amount: "currency" }). */ + fieldTypes?: Record; } export interface ProfileComponents { diff --git a/src/Expense/Expense.tsx b/src/Expense/Expense.tsx index 2afe060..b0b2df0 100644 --- a/src/Expense/Expense.tsx +++ b/src/Expense/Expense.tsx @@ -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(null); + const fieldConfigs = useMemo(() => { + 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,9 +171,9 @@ export default function Expense() { - + {fieldConfigs && } )} ); -} +} \ No newline at end of file diff --git a/src/Expense/ExpenseDetail.tsx b/src/Expense/ExpenseDetail.tsx index 015047d..203994b 100644 --- a/src/Expense/ExpenseDetail.tsx +++ b/src/Expense/ExpenseDetail.tsx @@ -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 ( - - - {label} - - {value} - - ); +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 ( - - {formatCurrency(item.amount, account?.currency)} - - } - /> - - - {account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"} - - {account?.type && ( - - )} - - } - /> - 0 ? ( - - {tags.map((tag, i) => ( - - ))} - - ) : ( - - No tags - - ) - } - /> - {formatDate(item.occurred_at)}} /> - - {item.id} - - } - /> - {formatDateTime(item.created_at)}} - /> + + + + + + + ); } diff --git a/src/Expense/ExpenseList.tsx b/src/Expense/ExpenseList.tsx index 39618a6..0e07670 100644 --- a/src/Expense/ExpenseList.tsx +++ b/src/Expense/ExpenseList.tsx @@ -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 ( - - {letter} - - ); -} - 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, }, }} > - + + {item.entity?.logo ? ( + + ) : ( + + )} + - {item.entity?.name ?? "Unknown"} + {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} - - - {formatDate(item.occurred_at)} - + + {item.account?.name && ( - )} - - {formatCurrency(item.amount, itemCurrency)} - + - + ); }); -export function ExpenseList({ items }: { items: ExpenseItem[] }) { +interface ExpenseListProps { + items: ExpenseItem[]; + fields: ExpenseFieldConfigs; +} + +export function ExpenseList({ items, fields }: ExpenseListProps) { const [expandedId, setExpandedId] = useState(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 }; \ No newline at end of file diff --git a/src/Expense/types.ts b/src/Expense/types.ts index 0effadf..746eaaf 100644 --- a/src/Expense/types.ts +++ b/src/Expense/types.ts @@ -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(); - -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(); - -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(); - -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 { @@ -74,4 +48,4 @@ export function monthLabel(key: string): string { if (!y || !m) return key; const d = new Date(y, m - 1, 1); return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" }); -} +} \ No newline at end of file diff --git a/src/openapi-config.ts b/src/openapi-config.ts index 9c08aea..5afc1f5 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -11,6 +11,7 @@ export const specConfiguration: SpecConfiguration = { resourceConfig: { expenses: { filterOptions: { mode: "client" }, + fieldTypes: { amount: "currency" }, }, }, };