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:
@@ -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";
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"} />;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const specConfiguration: SpecConfiguration = {
|
||||
resourceConfig: {
|
||||
expenses: {
|
||||
filterOptions: { mode: "client" },
|
||||
fieldTypes: { amount: "currency" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user