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