feat: add per-group metric strip in report transaction list

Compute Sum/Count/Avg/Min/Max/First/Last per accordion group in the
report view (mirroring backend ReportMetrics.compute_metrics signed
semantics) via new computeTxnMetrics helper and a showMetrics prop on
TransactionList. Remove the now-redundant top-level metric strip from
ReportViewer and restore its Outflows/Inflows/Transactions stat cards.
This commit is contained in:
2026-08-20 16:03:12 +05:30
parent 47c00a06a8
commit f9759e3968
7 changed files with 218 additions and 209 deletions

View File

@@ -110,12 +110,12 @@ export default function Expense() {
<>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 4 }}>
<StatCard
label="Total spent"
label="Outflows"
value={formatCurrency(summary.totalSpent, summary.currency)}
hint={`${summary.monthItems.length} transaction${summary.monthItems.length === 1 ? "" : "s"} this month`}
/>
<StatCard label="This month" value={formatCurrency(summary.monthTotal, summary.currency)} hint={summary.thisMonth} />
<StatCard label="Income" value={formatCurrency(summary.totalIncome, summary.currency)} />
<StatCard label="Inflows" value={formatCurrency(summary.totalIncome, summary.currency)} />
</Box>
{fieldConfigs && <TransactionList items={sorted} fields={fieldConfigs} />}

View File

@@ -1,5 +1,15 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Container, Box, Paper, Typography, Alert } from "@mui/material";
import {
Container,
Box,
Paper,
Typography,
Alert,
Accordion,
AccordionSummary,
AccordionDetails,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import AssessmentIcon from "@mui/icons-material/Assessment";
import { useResource, useAppContext, formatCurrency } from "../../react-openapi";
import { useToast } from "../ui/Toast";
@@ -103,31 +113,65 @@ export default function Report() {
<GenerateReportPanel onGenerated={handleGenerated} />
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em", mb: 1.5 }}>
Saved reports
</Typography>
{reports !== null && reports.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 3 }}>
<EmptyState
icon={<AssessmentIcon />}
title="No reports yet"
description="Generate your first report above — pick a granularity and payee (a payee-only or wildcard config snapshots weekly, monthly and quarterly)."
/>
</Paper>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5, mb: 4 }}>
<ReportList
reports={reports ?? []}
loading={loading}
fields={reportFields}
selectedId={selectedId}
onView={(id) => setSelectedId(id)}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
/>
</Box>
)}
<Accordion
disableGutters
defaultExpanded
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
overflow: "hidden",
boxShadow: "none",
backgroundColor: "background.paper",
"&:before": { display: "none" },
mb: 4,
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
px: 2.5,
py: 1,
"& .MuiAccordionSummary-content": {
alignItems: "center",
gap: 1.5,
minWidth: 0,
},
}}
>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
Saved reports
</Typography>
{reports !== null && (
<Typography variant="caption" color="text.secondary">
{reports.length} report{reports.length === 1 ? "" : "s"}
</Typography>
)}
</AccordionSummary>
<AccordionDetails sx={{ px: 2.5, pb: 2.5, pt: 0 }}>
{reports !== null && reports.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 3 }}>
<EmptyState
icon={<AssessmentIcon />}
title="No reports yet"
description="Generate your first report above — pick a granularity and payee (a payee-only or wildcard config snapshots weekly, monthly and quarterly)."
/>
</Paper>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<ReportList
reports={reports ?? []}
loading={loading}
fields={reportFields}
selectedId={selectedId}
onView={(id) => setSelectedId(id)}
onRegenerate={handleRegenerate}
onDelete={handleDelete}
/>
</Box>
)}
</AccordionDetails>
</Accordion>
{selectedId && (
<ReportViewer

View File

@@ -7,23 +7,16 @@ import {
IconButton,
Alert,
Skeleton,
TextField,
Autocomplete,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import CachedIcon from "@mui/icons-material/Cached";
import { useAppContext, useResource, formatCurrency } from "../../react-openapi";
import { useResource, formatCurrency } from "../../react-openapi";
import { StatCard } from "../common/components/StatCard";
import { TransactionList } from "../common/components/TransactionList";
import { OptionMultiSelect } from "../common/components/OptionMultiSelect";
import type { TxnFieldConfigs } from "../common/types";
import { toPeriodGranularity } from "../common/utils/transactions";
import { aggregateSlice, buildPivot, metricLabels } from "./types";
import { aggregateSlice } from "./types";
interface ReportViewerProps {
id: string;
@@ -34,15 +27,14 @@ interface ReportViewerProps {
}
export function ReportViewer({ id, version, fields, onClose, onRegenerated }: ReportViewerProps) {
const { schemas } = useAppContext();
const { get } = useResource("reports");
const [report, setReport] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [reload, setReload] = useState(0);
const [selectedPeriod, setSelectedPeriod] = useState("*");
const [selectedPayee, setSelectedPayee] = useState("*");
const [selectedPeriods, setSelectedPeriods] = useState<string[]>([]);
const [selectedPayees, setSelectedPayees] = useState<string[]>([]);
useEffect(() => {
let mounted = true;
@@ -52,8 +44,8 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
.then((res) => {
if (!mounted) return;
setReport(res);
setSelectedPeriod("*");
setSelectedPayee("*");
setSelectedPeriods([]);
setSelectedPayees([]);
})
.catch((e: any) => {
if (!mounted) return;
@@ -79,32 +71,10 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
}, [report]);
const slice = useMemo(
() => aggregateSlice(data ?? [], { period: selectedPeriod, payee: selectedPayee }),
[data, selectedPeriod, selectedPayee],
() => aggregateSlice(data ?? [], { periods: selectedPeriods, payees: selectedPayees }),
[data, selectedPeriods, selectedPayees],
);
const pivot = useMemo(
() => buildPivot(data ?? [], groupOptions.period, groupOptions.payee),
[data, groupOptions],
);
const metrics = useMemo(() => {
const labels = metricLabels(schemas);
const displayKeys = ["sum", "count", "avg", "min", "max", "first_date", "last_date"];
return labels.filter((l) => displayKeys.includes(l.key));
}, [schemas]);
const metricValue = (key: string): string | null => {
if (key === "sum") return formatCurrency(slice.sum, slice.currency);
if (key === "count") return slice.count.toLocaleString("en-IN");
if (key === "avg") return slice.avg == null ? null : formatCurrency(slice.avg, slice.currency);
if (key === "min") return slice.min == null ? null : formatCurrency(slice.min, slice.currency);
if (key === "max") return slice.max == null ? null : formatCurrency(slice.max, slice.currency);
if (key === "first_date") return slice.firstDate;
if (key === "last_date") return slice.lastDate;
return null;
};
if (loading) {
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
@@ -179,23 +149,17 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
) : (
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 2.5 }}>
<Autocomplete
size="small"
sx={{ width: 220 }}
options={["*", ...groupOptions.period]}
value={selectedPeriod}
onChange={(_, v) => setSelectedPeriod(v ?? "*")}
disabled={groupOptions.period.length === 0}
renderInput={(params) => <TextField {...params} label="Period" />}
<OptionMultiSelect
label="Period"
options={groupOptions.period}
value={selectedPeriods}
onChange={setSelectedPeriods}
/>
<Autocomplete
size="small"
sx={{ width: 220 }}
options={["*", ...groupOptions.payee]}
value={selectedPayee}
onChange={(_, v) => setSelectedPayee(v ?? "*")}
disabled={groupOptions.payee.length === 0}
renderInput={(params) => <TextField {...params} label="Payee" />}
<OptionMultiSelect
label="Payee"
options={groupOptions.payee}
value={selectedPayees}
onChange={setSelectedPayees}
/>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: "center" }}>
Showing {slice.count} transactions across {slice.txns.length} rows
@@ -203,78 +167,11 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 2.5 }}>
<StatCard label="Spent" value={formatCurrency(slice.spent, slice.currency)} color="error.main" />
<StatCard label="Income" value={formatCurrency(slice.income, slice.currency)} color="success.main" />
<StatCard label="Outflows" value={formatCurrency(slice.spent, slice.currency)} color="error.main" />
<StatCard label="Inflows" value={formatCurrency(slice.income, slice.currency)} color="success.main" />
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
</Box>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 3 }}>
{metrics.map((m) => {
const value = metricValue(m.key);
if (value == null) return null;
return (
<Paper key={m.key} variant="outlined" sx={{ px: 1.5, py: 1, borderRadius: 2 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{m.label}
</Typography>
<Typography variant="body2" fontWeight={600}>
{value}
</Typography>
</Paper>
);
})}
</Box>
{selectedPeriod === "*" && selectedPayee === "*" && pivot.rows.length > 0 && (
<TableContainer
component={Paper}
variant="outlined"
sx={{ borderRadius: 2, mb: 3, overflowX: "auto" }}
>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ fontWeight: 700 }}>Period</TableCell>
{pivot.payees.map((payee) => (
<TableCell key={payee} align="right" sx={{ fontWeight: 700 }}>
{payee}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 700 }}>
Total
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{pivot.rows.map((row) => (
<TableRow key={row.period} sx={{ "&:last-child td": { borderBottom: 0 } }}>
<TableCell sx={{ fontWeight: 600 }}>{row.period}</TableCell>
{row.cells.map((cell) => (
<TableCell key={cell.payee} align="right">
{cell.sum === 0 ? "—" : formatCurrency(cell.sum, slice.currency)}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 700 }}>
{formatCurrency(row.periodSum, slice.currency)}
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell sx={{ fontWeight: 700 }}>Total</TableCell>
{pivot.totals.map((t) => (
<TableCell key={t.payee} align="right" sx={{ fontWeight: 700 }}>
{formatCurrency(t.sum, slice.currency)}
</TableCell>
))}
<TableCell align="right" sx={{ fontWeight: 700 }}>
{formatCurrency(pivot.rows.reduce((s, r) => s + r.periodSum, 0), slice.currency)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
)}
{slice.txns.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3 }}>
<Typography variant="body2" color="text.secondary">
@@ -282,7 +179,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
</Typography>
</Paper>
) : fields ? (
<TransactionList items={slice.txns} fields={fields} granularity={toPeriodGranularity(report.granularity)} />
<TransactionList items={slice.txns} fields={fields} granularity={toPeriodGranularity(report.granularity)} showMetrics />
) : null}
</Box>
)}

View File

@@ -15,8 +15,8 @@ export interface ReportGroupLike {
}
export interface SliceFilter {
period?: string;
payee?: string;
periods?: string[];
payees?: string[];
}
export interface SliceSummary {
@@ -33,25 +33,6 @@ export interface SliceSummary {
currency: string;
}
export interface PivotCell {
payee: string;
sum: number;
count: number;
}
export interface PivotRow {
period: string;
cells: PivotCell[];
periodSum: number;
periodCount: number;
}
export interface PivotTable {
rows: PivotRow[];
payees: string[];
totals: PivotCell[];
}
export interface ReportFieldConfigs {
groupLabel: FieldConfig;
granularity: FieldConfig;
@@ -142,11 +123,11 @@ function dateVal(value: string): number {
export function groupMatches(group: ReportGroupLike, filter: SliceFilter): boolean {
const key = parseCacheKey(group.key);
if (filter.period && filter.period !== "*") {
if (!key.period || key.period.label !== filter.period) return false;
if (filter.periods && filter.periods.length > 0) {
if (!key.period || !filter.periods.includes(key.period.label)) return false;
}
if (filter.payee && filter.payee !== "*") {
if (!key.payee || key.payee.label !== filter.payee) return false;
if (filter.payees && filter.payees.length > 0) {
if (!key.payee || !filter.payees.includes(key.payee.label)) return false;
}
return true;
}
@@ -194,35 +175,6 @@ export function aggregateSlice(groups: ReportGroupLike[], filter: SliceFilter):
return { sum, count, avg: count ? sum / count : null, min, max, firstDate, lastDate, txns, spent, income, currency };
}
export function buildPivot(groups: ReportGroupLike[], periodOrder: string[], payeeOrder: string[]): PivotTable {
const payees = payeeOrder.filter((payee) =>
groups.some((g) => parseCacheKey(g.key).payee?.label === payee),
);
const totals = payees.map((payee) => ({ payee, sum: 0, count: 0 }));
const rows: PivotRow[] = [];
for (const period of periodOrder) {
const periodGroups = groups.filter((g) => parseCacheKey(g.key).period?.label === period);
if (periodGroups.length === 0) continue;
const cells = payees.map((payee, i) => {
const cellGroups = periodGroups.filter((g) => parseCacheKey(g.key).payee?.label === payee);
const sum = cellGroups.reduce((s, g) => s + (g.metrics?.sum ?? 0), 0);
const count = cellGroups.reduce((s, g) => s + (g.metrics?.count ?? 0), 0);
totals[i].sum += sum;
totals[i].count += count;
return { payee, sum, count };
});
rows.push({
period,
cells,
periodSum: cells.reduce((s, c) => s + c.sum, 0),
periodCount: cells.reduce((s, c) => s + c.count, 0),
});
}
return { rows, payees, totals };
}
export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFieldConfigs | null {
const reportsRes = resources.find((r) => r.name === "reports");
if (!reportsRes) return null;

View File

@@ -0,0 +1,55 @@
import React, { useMemo } from "react";
import { Autocomplete, TextField, Chip, Box } from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
interface OptionMultiSelectProps {
label: string;
options: string[];
value: string[];
onChange: (value: string[]) => void;
}
export function OptionMultiSelect({ label, options, value, onChange }: OptionMultiSelectProps) {
const sortedOptions = useMemo(() => {
const sel = new Set(value);
const picked: string[] = [];
const rest: string[] = [];
for (const opt of options) {
(sel.has(opt) ? picked : rest).push(opt);
}
return [...picked, ...rest];
}, [options, value]);
return (
<Autocomplete
multiple
size="small"
sx={{ width: 260 }}
options={sortedOptions}
value={value}
onChange={(_, newVal) => onChange(newVal)}
disabled={options.length === 0}
renderOption={(props, option, { selected }) => (
<li {...props}>
{selected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: "primary.main" }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
)}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip key={key} {...tagProps} label={tag.length > 12 ? `${tag.slice(0, 10)}..` : tag} size="small" />;
})}
{tagValue.length > maxChips && <Chip label={`+${tagValue.length - maxChips}`} size="small" />}
</>
);
}}
renderInput={(params) => (
<TextField {...params} label={label} placeholder={value.length === 0 ? "All" : undefined} />
)}
/>
);
}

View File

@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
@@ -14,7 +15,7 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { formatCurrency } from "../../../react-openapi";
import type { ExpenseItem, TxnFieldConfigs } from "../types";
import { groupByDate, groupByPeriod } from "../utils/transactions";
import { computeTxnMetrics, groupByDate, groupByPeriod } from "../utils/transactions";
import type { PeriodGranularity } from "../utils/transactions";
import { TransactionRow } from "./TransactionRow";
@@ -22,9 +23,37 @@ interface TransactionListProps {
items: ExpenseItem[];
fields: TxnFieldConfigs;
granularity?: PeriodGranularity;
showMetrics?: boolean;
}
export function TransactionList({ items, fields, granularity = "monthly" }: TransactionListProps) {
function GroupMetrics({ items, currency }: { items: ExpenseItem[]; currency: string }) {
const m = computeTxnMetrics(items);
const rows: { label: string; value: string }[] = [
{ label: "Sum", value: formatCurrency(m.sum, currency) },
{ label: "Count", value: m.count.toLocaleString("en-IN") },
{ label: "Avg", value: m.avg == null ? "—" : formatCurrency(m.avg, currency) },
{ label: "Min", value: m.min == null ? "—" : formatCurrency(m.min, currency) },
{ label: "Max", value: m.max == null ? "—" : formatCurrency(m.max, currency) },
{ label: "First", value: m.firstDate ?? "—" },
{ label: "Last", value: m.lastDate ?? "—" },
];
return (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
{rows.map((row) => (
<Paper key={row.label} variant="outlined" sx={{ px: 1.25, py: 0.5, borderRadius: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{row.label}
</Typography>
<Typography variant="body2" fontWeight={600}>
{row.value}
</Typography>
</Paper>
))}
</Box>
);
}
export function TransactionList({ items, fields, granularity = "monthly", showMetrics = false }: TransactionListProps) {
const [activeMonth, setActiveMonth] = useState<string | null>(null);
const [openMonth, setOpenMonth] = useState<string | null>(null);
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
@@ -160,6 +189,7 @@ export function TransactionList({ items, fields, granularity = "monthly" }: Tran
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
{showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{groupByDate(group.items).map((dateGroup) => (
<Box

View File

@@ -106,4 +106,35 @@ export function groupByPeriod(items: ExpenseItem[], granularity: PeriodGranulari
return { key, label: periodLabel(key, granularity), items: sorted, spent, income, currency };
})
.sort((a, b) => b.key.localeCompare(a.key));
}
export interface TxnMetrics {
sum: number;
count: number;
avg: number | null;
min: number | null;
max: number | null;
firstDate: string | null;
lastDate: string | null;
}
/** Mirrors backend ReportMetrics.compute_metrics over a list of transactions. */
export function computeTxnMetrics(items: ExpenseItem[]): TxnMetrics {
const amounts = items.filter((it) => typeof it.amount === "number").map((it) => it.amount as number);
if (amounts.length === 0) {
return { sum: 0, count: 0, avg: null, min: null, max: null, firstDate: null, lastDate: null };
}
const sorted = [...items].sort(
(a, b) => parseOccurredAt(a.occurred_at).getTime() - parseOccurredAt(b.occurred_at).getTime(),
);
const sum = amounts.reduce((s, a) => s + a, 0);
return {
sum,
count: amounts.length,
avg: sum / amounts.length,
min: Math.min(...amounts),
max: Math.max(...amounts),
firstDate: sorted[0]?.occurred_at ?? null,
lastDate: sorted[sorted.length - 1]?.occurred_at ?? null,
};
}