3 Commits

Author SHA1 Message Date
537aef94ff resolve relative media URLs against the API base in react-openapi
- add resolveMediaUrl() in fields/utils.ts: prefixes /uploads/... and
  other relative paths with the configured baseApiUrl, leaves absolute
  http/data/blob URLs untouched
- use it for every image render: ListCellRenderer, DetailFieldRenderer,
  ImageField form preview, and FileUploadField preview/href (which
  hardcoded /uploads/<value>)
- drop the Expense-side resolveLogoUrl hack and logo pre-normalization;
  the Expenses page now renders logos through the same react-openapi
  path as the admin entity list
2026-08-18 13:01:55 +05:30
47d799fe5f expense list: clickable month pill + fix scroll-to-month
- make the floating month pill interactive (button role, hover,
  focus ring) and jump to the active month's header on click/Enter
- scroll via a non-sticky month anchor + window.scrollTo instead of
  scrollIntoView, which no-ops on sticky headers that are always
  already in view
2026-08-17 20:38:28 +05:30
cc940ee6e3 expense list: split month totals, sticky headers + month pill
- show spent and income totals separately per month header
  (debits red, credits green) instead of a single net amount
- pin the current month header below the navbar while scrolling
  its section (glass backdrop, divider), Stripe-style grouping
- add a floating month pill that updates via scroll-spy so the
  active month stays visible across long feeds
2026-08-17 20:15:39 +05:30
8 changed files with 178 additions and 37 deletions

View File

@@ -3,6 +3,8 @@ import { Box, Typography, Avatar } from "@mui/material";
import type { FieldConfig } from "../../types";
import { ListCellRenderer } from "./ListCellRenderer";
import { CurrencyField } from "./renderers/CurrencyField";
import { resolveMediaUrl } from "./utils";
import { useAppContext } from "../../context/AppContext";
interface DetailFieldProps {
field: FieldConfig;
@@ -12,6 +14,7 @@ interface DetailFieldProps {
}
export function DetailFieldRenderer({ field, value, displayFormat, basePath }: DetailFieldProps) {
const { config } = useAppContext();
if (field.hidden?.detail) return null;
return (
@@ -22,7 +25,7 @@ export function DetailFieldRenderer({ field, value, displayFormat, basePath }: D
{field.uiType === "currency" ? (
<CurrencyField value={Number(value)} large />
) : field.uiType === "image" ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
<Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<ListCellRenderer field={field} value={value} displayFormat={displayFormat} basePath={basePath} />
)}

View File

@@ -2,7 +2,7 @@ import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Typography, Chip, Avatar, Dialog, DialogTitle, DialogContent, DialogActions, Button, Grid } from "@mui/material";
import type { FieldConfig } from "../../types";
import { applyDisplayFormat } from "./utils";
import { applyDisplayFormat, resolveMediaUrl } from "./utils";
import { InlineRefField } from "./renderers/InlineRefField";
import { CurrencyField } from "./renderers/CurrencyField";
import { extractFields } from "../../transformers/field-config";
@@ -17,7 +17,7 @@ interface ListCellProps {
export function ListCellRenderer({ field, value, displayFormat, basePath }: ListCellProps) {
const navigate = useNavigate();
const { schemas } = useAppContext();
const { schemas, config } = useAppContext();
const [inlineItem, setInlineItem] = useState<any>(null);
if (value === null || value === undefined) {
@@ -138,7 +138,7 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List
}
if (field.uiType === "image" && value) {
return <Avatar src={value} variant="rounded" sx={{ width: 40, height: 40 }} />;
return <Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 40, height: 40 }} />;
}
if (field.uiType === "currency" && value != null && !Number.isNaN(Number(value))) {

View File

@@ -2,6 +2,8 @@ import React from "react";
import { Box, Typography, Avatar, Chip, Button, FormHelperText } from "@mui/material";
import type { FieldConfig } from "../../../types";
import { getApi } from "../../../hooks/useApi";
import { useAppContext } from "../../../context/AppContext";
import { resolveMediaUrl } from "../utils";
interface Props {
field: FieldConfig;
@@ -19,6 +21,7 @@ const acceptMap: Record<string, string> = {
export function FileUploadField({ field, value, onChange }: Props) {
const uploadConfig = field.upload!;
const inputRef = React.useRef<HTMLInputElement>(null);
const { config } = useAppContext();
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -51,12 +54,12 @@ export function FileUploadField({ field, value, onChange }: Props) {
</Typography>
{value ? (
uploadConfig.type === "image" ? (
<Avatar src={`/uploads/${value}`} variant="rounded" sx={{ width: 120, height: 120 }} />
<Avatar src={resolveMediaUrl(`/uploads/${value}`, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<Chip
label={value}
component="a"
href={`/uploads/${value}`}
href={resolveMediaUrl(`/uploads/${value}`, config.baseApiUrl)}
clickable
onDelete={handleReplace}
/>

View File

@@ -3,6 +3,8 @@ import { Box, Typography, Avatar, FormHelperText } from "@mui/material";
import Button from "@mui/material/Button";
import type { FieldConfig } from "../../../types";
import { getApi } from "../../../hooks/useApi";
import { useAppContext } from "../../../context/AppContext";
import { resolveMediaUrl } from "../utils";
interface Props {
field: FieldConfig;
@@ -13,6 +15,7 @@ interface Props {
}
export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
const { config } = useAppContext();
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -47,7 +50,7 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) {
{field.label}
</Typography>
{value ? (
<Avatar src={value} variant="rounded" sx={{ width: 120, height: 120 }} />
<Avatar src={resolveMediaUrl(value, config.baseApiUrl)} variant="rounded" sx={{ width: 120, height: 120 }} />
) : (
<Button variant="outlined" component="label" size="small">
Upload {field.label}

View File

@@ -9,3 +9,15 @@ export function applyDisplayFormat(item: any, format: string): string {
return val != null ? String(val) : "";
});
}
/**
* Resolve a media/image URL against the API base so relative paths
* like `/uploads/entity_logos/x.svg` point at the backend origin.
* Absolute (http/data/blob) URLs are returned untouched.
*/
export function resolveMediaUrl(value: string | null | undefined, baseUrl?: string): string | undefined {
if (value == null || value === "") return undefined;
if (value.startsWith("http") || value.startsWith("data:") || value.startsWith("blob:")) return value;
if (value.startsWith("/") && baseUrl) return `${baseUrl.replace(/\/+$/, "")}${value}`;
return value;
}

View File

@@ -14,9 +14,7 @@ import { useResource, useAppContext, formatCurrency } from "../../react-openapi"
import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState";
import { ExpenseList } from "./ExpenseList";
import { ExpenseItem, ExpenseFieldConfigs, isExpense, currentMonthKey, monthKey, monthLabel, parseOccurredAt, resolveLogoUrl } from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL;
import { ExpenseItem, ExpenseFieldConfigs, isExpense, currentMonthKey, monthKey, monthLabel, parseOccurredAt } from "./types";
function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) {
return (
@@ -77,14 +75,7 @@ export default function Expense() {
let mounted = true;
list({ limit: 0 }).then((res) => {
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);
setItems((res.items ?? []) as ExpenseItem[]);
});
return () => {
mounted = false;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Typography,
@@ -6,16 +6,19 @@ import {
AccordionSummary,
AccordionDetails,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi";
import type { ExpenseItem, ExpenseFieldConfigs } from "./types";
import { monthKey, monthLabel, parseOccurredAt } from "./types";
import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types";
import { ExpenseDetail } from "./ExpenseDetail";
interface GroupedMonth {
key: string;
items: ExpenseItem[];
total: number;
spent: number;
income: number;
currency: string;
}
@@ -33,8 +36,9 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] {
(a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(),
);
const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR";
const total = sorted.reduce((sum, it) => sum + (it.amount ?? 0), 0);
return { key, items: sorted, total, currency };
const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0);
const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0);
return { key, items: sorted, spent, income, currency };
})
.sort((a, b) => b.key.localeCompare(a.key));
}
@@ -129,16 +133,92 @@ interface ExpenseListProps {
export function ExpenseList({ items, fields }: ExpenseListProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [activeMonth, setActiveMonth] = useState<string | null>(null);
const groups = useMemo(() => groupByMonth(items), [items]);
const listRef = useRef<HTMLDivElement>(null);
const handleToggle = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
}, []);
const jumpToActiveMonth = useCallback(() => {
if (!activeMonth) return;
const anchor = listRef.current?.querySelector<HTMLElement>(
`[data-month-anchor="${activeMonth}"]`,
);
if (!anchor) return;
const offset = window.matchMedia("(min-width: 900px)").matches ? 64 : 56;
const top = anchor.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top, behavior: "smooth" });
}, [activeMonth]);
const handlePillKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
jumpToActiveMonth();
}
},
[jumpToActiveMonth],
);
useEffect(() => {
const root = listRef.current;
if (!root || groups.length === 0) return;
let ticking = false;
const update = () => {
ticking = false;
const headers = root.querySelectorAll<HTMLElement>("[data-month-header]");
let current: string | null = null;
for (const header of headers) {
if (header.getBoundingClientRect().top <= 72) {
current = header.dataset.monthHeader ?? null;
} else {
break;
}
}
setActiveMonth(current);
};
const onScroll = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
};
update();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [groups]);
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
{groups.map((group) => (
<Box key={group.key}>
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1.5, mb: 1.5 }}>
<>
<Box ref={listRef} sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
{groups.map((group) => (
<Box key={group.key}>
<span data-month-anchor={group.key} aria-hidden="true" style={{ display: "block", height: 0 }} />
<Box
data-month-header={group.key}
sx={{
display: "flex",
alignItems: "baseline",
gap: 1.5,
mb: 1.5,
position: "sticky",
top: { xs: 56, md: 64 },
zIndex: 2,
py: 0.5,
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85),
backdropFilter: "blur(8px)",
borderBottom: "1px solid",
borderColor: "divider",
}}
>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{monthLabel(group.key)}
</Typography>
@@ -146,8 +226,14 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="body2" fontWeight={600}>
{formatCurrency(group.total, group.currency)}
<Typography variant="body2" fontWeight={700} color="error.main">
{formatCurrency(group.spent, group.currency)}
</Typography>
<Typography variant="body2" color="text.disabled">
/
</Typography>
<Typography variant="body2" fontWeight={700} color="success.main">
{formatCurrency(group.income, group.currency)}
</Typography>
</Box>
@@ -165,7 +251,57 @@ export function ExpenseList({ items, fields }: ExpenseListProps) {
</Box>
</Box>
))}
</Box>
</Box>
<Box
sx={{
position: "fixed",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
zIndex: 3,
pointerEvents: activeMonth ? "auto" : "none",
opacity: activeMonth ? 1 : 0,
transition: "opacity 160ms ease",
}}
>
<Box
role="button"
tabIndex={0}
onClick={jumpToActiveMonth}
onKeyDown={handlePillKeyDown}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1.5,
py: 0.75,
borderRadius: "999px",
cursor: "pointer",
userSelect: "none",
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85),
backdropFilter: "blur(8px)",
border: "1px solid",
borderColor: "divider",
boxShadow: 1,
transition: "background-color 160ms ease, transform 160ms ease",
"&:hover": {
backgroundColor: (theme) => alpha(theme.palette.background.default, 0.95),
transform: "translateY(-1px)",
},
"&:focus-visible": {
outline: "2px solid",
outlineColor: "primary.main",
},
}}
>
<KeyboardArrowUpIcon sx={{ fontSize: 14, color: "text.secondary" }} />
<Typography variant="caption" fontWeight={700} color="text.secondary">
{activeMonth ? monthLabel(activeMonth) : ""}
</Typography>
</Box>
</Box>
</>
);
}

View File

@@ -29,13 +29,6 @@ export function isExpense(item: ExpenseItem): boolean {
return (item.amount ?? 0) < 0;
}
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 parseOccurredAt(value?: string): Date {
const m = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/);
if (!m) {