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 { useResource } from "./src/context/useResource";
|
||||||
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
|
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
|
||||||
export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
|
export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
|
||||||
|
export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField";
|
||||||
export { SseStreamView } from "./src/components/SseStreamView";
|
export { SseStreamView } from "./src/components/SseStreamView";
|
||||||
export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
|
export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
|
||||||
export { getApi } from "./src/hooks/useApi";
|
export { getApi } from "./src/hooks/useApi";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from "react";
|
|||||||
import { Box, Typography, Avatar } from "@mui/material";
|
import { Box, Typography, Avatar } from "@mui/material";
|
||||||
import type { FieldConfig } from "../../types";
|
import type { FieldConfig } from "../../types";
|
||||||
import { ListCellRenderer } from "./ListCellRenderer";
|
import { ListCellRenderer } from "./ListCellRenderer";
|
||||||
|
import { CurrencyField } from "./renderers/CurrencyField";
|
||||||
|
|
||||||
interface DetailFieldProps {
|
interface DetailFieldProps {
|
||||||
field: FieldConfig;
|
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" }}>
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 0.25, display: "block" }}>
|
||||||
{field.label}
|
{field.label}
|
||||||
</Typography>
|
</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 }} />
|
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
|
||||||
) : (
|
) : (
|
||||||
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
|
<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} />;
|
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") {
|
if (field.type === "integer" || field.type === "number") {
|
||||||
return (
|
return (
|
||||||
<NumberField
|
<NumberField
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, Dial
|
|||||||
import type { FieldConfig } from "../../types";
|
import type { FieldConfig } from "../../types";
|
||||||
import { applyDisplayFormat } from "./utils";
|
import { applyDisplayFormat } from "./utils";
|
||||||
import { InlineRefField } from "./renderers/InlineRefField";
|
import { InlineRefField } from "./renderers/InlineRefField";
|
||||||
|
import { CurrencyField } from "./renderers/CurrencyField";
|
||||||
import { extractFields } from "../../transformers/field-config";
|
import { extractFields } from "../../transformers/field-config";
|
||||||
import { useAppContext } from "../../context/AppContext";
|
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 }} />;
|
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") {
|
if (field.type === "boolean") {
|
||||||
return <Chip label={value ? "Yes" : "No"} size="small" color={value ? "success" : "default"} />;
|
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 { DetailFieldRenderer } from "./DetailFieldRenderer";
|
||||||
export { applyDisplayFormat } from "./utils";
|
export { applyDisplayFormat } from "./utils";
|
||||||
export { JsonField } from "./renderers/JsonField";
|
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;
|
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 = {
|
const DEFAULT_AUTH_CONFIG: AuthConfig = {
|
||||||
serverUrl: "",
|
serverUrl: "",
|
||||||
loginPath: "/login",
|
loginPath: "/login",
|
||||||
@@ -88,7 +102,7 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) {
|
|||||||
if (errs.length === 0) {
|
if (errs.length === 0) {
|
||||||
const configs = buildResourceConfigs(spec);
|
const configs = buildResourceConfigs(spec);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setResources(configs);
|
setResources(applyResourceOverrides(configs, specConfiguration));
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? "";
|
const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? "";
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface ResourceConfiguration {
|
|||||||
filterOptions?: {
|
filterOptions?: {
|
||||||
mode?: FilterMode;
|
mode?: FilterMode;
|
||||||
};
|
};
|
||||||
|
/** Map of field name → uiType override (e.g. { amount: "currency" }). */
|
||||||
|
fieldTypes?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileComponents {
|
export interface ProfileComponents {
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
|
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useResource } from "../../react-openapi";
|
import { useResource, useAppContext, formatCurrency } from "../../react-openapi";
|
||||||
import { PageHeader } from "../ui/PageHeader";
|
import { PageHeader } from "../ui/PageHeader";
|
||||||
import { EmptyState } from "../ui/EmptyState";
|
import { EmptyState } from "../ui/EmptyState";
|
||||||
import { ExpenseList } from "./ExpenseList";
|
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 }) {
|
function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -39,13 +41,50 @@ function StatCard({ label, value, hint }: { label: string; value: string; hint?:
|
|||||||
|
|
||||||
export default function Expense() {
|
export default function Expense() {
|
||||||
const navigate = useNavigate();
|
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 [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(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
list({ limit: 0 }).then((res) => {
|
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 () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
@@ -132,9 +171,9 @@ export default function Expense() {
|
|||||||
<StatCard label="Income" value={formatCurrency(summary.totalIncome, summary.currency)} />
|
<StatCard label="Income" value={formatCurrency(summary.totalIncome, summary.currency)} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<ExpenseList items={sorted} />
|
{fieldConfigs && <ExpenseList items={sorted} fields={fieldConfigs} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,87 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, Typography, Chip, Divider } from "@mui/material";
|
import { Box, Divider } from "@mui/material";
|
||||||
import type { ExpenseItem } from "./types";
|
import { DetailFieldRenderer } from "../../react-openapi";
|
||||||
import { formatCurrency, formatDate, formatDateTime } from "./types";
|
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
interface ExpenseDetailProps {
|
||||||
return (
|
item: ExpenseItem;
|
||||||
<Box sx={{ display: "flex", gap: 2, alignItems: "baseline", py: 0.75 }}>
|
fields: ExpenseFieldConfigs;
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ width: 160, flexShrink: 0 }}>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>{value}</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpenseDetail({ item }: { item: ExpenseItem }) {
|
export function ExpenseDetail({ item, fields }: ExpenseDetailProps) {
|
||||||
const account = item.account;
|
|
||||||
const tags = item.tags ?? [];
|
|
||||||
const last4 = account?.number ? `…${account.number.slice(-4)}` : "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ pt: 1, pb: 0.5 }}>
|
<Box sx={{ pt: 1, pb: 0.5 }}>
|
||||||
<Divider sx={{ mb: 1.5 }} />
|
<Divider sx={{ mb: 1.5 }} />
|
||||||
<Row
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||||
label="Amount"
|
<DetailFieldRenderer field={fields.entity} value={item.entity} displayFormat={fields.formats.entity} />
|
||||||
value={
|
<DetailFieldRenderer field={fields.amount} value={item.amount} />
|
||||||
<Typography variant="body2" fontWeight={600}>
|
<DetailFieldRenderer field={fields.account} value={item.account} displayFormat={fields.formats.account} />
|
||||||
{formatCurrency(item.amount, account?.currency)}
|
<DetailFieldRenderer field={fields.tags} value={item.tags} displayFormat={fields.formats.tags} />
|
||||||
</Typography>
|
<DetailFieldRenderer field={fields.occurredAt} value={item.occurred_at} />
|
||||||
}
|
</Box>
|
||||||
/>
|
|
||||||
<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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ import {
|
|||||||
Accordion,
|
Accordion,
|
||||||
AccordionSummary,
|
AccordionSummary,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
Avatar,
|
|
||||||
Chip,
|
|
||||||
useTheme,
|
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
import type { ExpenseItem } from "./types";
|
import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
|
||||||
import { formatCurrency, formatDate, isExpense, monthKey, monthLabel } from "./types";
|
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
|
||||||
|
import { monthKey, monthLabel } from "./types";
|
||||||
import { ExpenseDetail } from "./ExpenseDetail";
|
import { ExpenseDetail } from "./ExpenseDetail";
|
||||||
|
|
||||||
interface GroupedMonth {
|
interface GroupedMonth {
|
||||||
@@ -41,40 +39,15 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] {
|
|||||||
.sort((a, b) => b.key.localeCompare(a.key));
|
.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 {
|
interface ExpenseCardProps {
|
||||||
item: ExpenseItem;
|
item: ExpenseItem;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
currency: string;
|
currency: string;
|
||||||
|
fields: ExpenseFieldConfigs;
|
||||||
onToggle: (id: string) => void;
|
onToggle: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, onToggle }: ExpenseCardProps) {
|
const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, fields, onToggle }: ExpenseCardProps) {
|
||||||
const negative = isExpense(item);
|
|
||||||
const itemCurrency = item.account?.currency ?? currency;
|
const itemCurrency = item.account?.currency ?? currency;
|
||||||
|
|
||||||
return (
|
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 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body1"
|
variant="body1"
|
||||||
@@ -113,43 +100,34 @@ const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency,
|
|||||||
noWrap
|
noWrap
|
||||||
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
|
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
|
||||||
>
|
>
|
||||||
{item.entity?.name ?? "Unknown"}
|
{item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 0.25 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 0.25, flexWrap: "wrap" }}>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<ListCellRenderer field={fields.occurredAt} value={item.occurred_at} />
|
||||||
{formatDate(item.occurred_at)}
|
|
||||||
</Typography>
|
|
||||||
{item.account?.name && (
|
{item.account?.name && (
|
||||||
<Chip
|
<ListCellRenderer
|
||||||
size="small"
|
field={fields.account}
|
||||||
label={item.account.name}
|
value={item.account}
|
||||||
variant="outlined"
|
displayFormat={fields.formats.account}
|
||||||
sx={{ height: 20, fontSize: 11, "& .MuiChip-label": { px: 1 } }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<CurrencyField value={item.amount} currency={itemCurrency} large />
|
||||||
variant="body1"
|
|
||||||
fontWeight={700}
|
|
||||||
sx={{
|
|
||||||
fontSize: "0.9375rem",
|
|
||||||
fontVariantNumeric: "tabular-nums",
|
|
||||||
color: negative ? "error.main" : "success.main",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatCurrency(item.amount, itemCurrency)}
|
|
||||||
</Typography>
|
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails sx={{ pt: 0 }}>
|
<AccordionDetails sx={{ pt: 0 }}>
|
||||||
<ExpenseDetail item={item} />
|
<ExpenseDetail item={item} fields={fields} />
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</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 [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const groups = useMemo(() => groupByMonth(items), [items]);
|
const groups = useMemo(() => groupByMonth(items), [items]);
|
||||||
const handleToggle = useCallback((id: string) => {
|
const handleToggle = useCallback((id: string) => {
|
||||||
@@ -180,6 +158,7 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
|
|||||||
item={item}
|
item={item}
|
||||||
expanded={expandedId === item.id}
|
expanded={expandedId === item.id}
|
||||||
currency={group.currency}
|
currency={group.currency}
|
||||||
|
fields={fields}
|
||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -190,4 +169,4 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { groupByMonth };
|
export { groupByMonth };
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { FieldConfig } from "../../react-openapi";
|
||||||
|
|
||||||
export interface ExpenseItem {
|
export interface ExpenseItem {
|
||||||
id: string;
|
id: string;
|
||||||
entity?: { name?: string; type?: string; logo?: string } | null;
|
entity?: { name?: string; type?: string; logo?: string } | null;
|
||||||
@@ -9,57 +11,29 @@ export interface ExpenseItem {
|
|||||||
updated_at?: string;
|
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 {
|
export function isExpense(item: ExpenseItem): boolean {
|
||||||
return (item.amount ?? 0) < 0;
|
return (item.amount ?? 0) < 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"];
|
export function resolveLogoUrl(logo?: string, base?: string): string | undefined {
|
||||||
const _currencyFormatters = new Map<string, Intl.NumberFormat>();
|
if (!logo) return undefined;
|
||||||
|
if (logo.startsWith("http") || logo.startsWith("data:")) return logo;
|
||||||
export function formatCurrency(amount: number, currency?: string): string {
|
if (logo.startsWith("/") && base) return `${base.replace(/\/+$/, "")}${logo}`;
|
||||||
const code = currency && CURRENCIES.includes(currency) ? currency : "INR";
|
return logo;
|
||||||
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 monthKey(iso?: string): string {
|
export function monthKey(iso?: string): string {
|
||||||
@@ -74,4 +48,4 @@ export function monthLabel(key: string): string {
|
|||||||
if (!y || !m) return key;
|
if (!y || !m) return key;
|
||||||
const d = new Date(y, m - 1, 1);
|
const d = new Date(y, m - 1, 1);
|
||||||
return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" });
|
return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" });
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ export const specConfiguration: SpecConfiguration = {
|
|||||||
resourceConfig: {
|
resourceConfig: {
|
||||||
expenses: {
|
expenses: {
|
||||||
filterOptions: { mode: "client" },
|
filterOptions: { mode: "client" },
|
||||||
|
fieldTypes: { amount: "currency" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user