From f9759e3968e1be72b65b3e2af9d043c22ce15e06 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 16:03:12 +0530 Subject: [PATCH] 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. --- src/Expense.tsx | 4 +- src/Reports/Report.tsx | 96 +++++++++---- src/Reports/ReportViewer.tsx | 147 +++----------------- src/Reports/types.ts | 60 +------- src/common/components/OptionMultiSelect.tsx | 55 ++++++++ src/common/components/TransactionList.tsx | 34 ++++- src/common/utils/transactions.ts | 31 +++++ 7 files changed, 218 insertions(+), 209 deletions(-) create mode 100644 src/common/components/OptionMultiSelect.tsx diff --git a/src/Expense.tsx b/src/Expense.tsx index 8953dc7..29e7093 100644 --- a/src/Expense.tsx +++ b/src/Expense.tsx @@ -110,12 +110,12 @@ export default function Expense() { <> - + {fieldConfigs && } diff --git a/src/Reports/Report.tsx b/src/Reports/Report.tsx index 4b044f9..d8426b0 100644 --- a/src/Reports/Report.tsx +++ b/src/Reports/Report.tsx @@ -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() { - - Saved reports - - - {reports !== null && reports.length === 0 ? ( - - } - 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)." - /> - - ) : ( - - setSelectedId(id)} - onRegenerate={handleRegenerate} - onDelete={handleDelete} - /> - - )} + + } + sx={{ + px: 2.5, + py: 1, + "& .MuiAccordionSummary-content": { + alignItems: "center", + gap: 1.5, + minWidth: 0, + }, + }} + > + + Saved reports + + {reports !== null && ( + + {reports.length} report{reports.length === 1 ? "" : "s"} + + )} + + + {reports !== null && reports.length === 0 ? ( + + } + 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)." + /> + + ) : ( + + setSelectedId(id)} + onRegenerate={handleRegenerate} + onDelete={handleDelete} + /> + + )} + + {selectedId && ( (null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reload, setReload] = useState(0); - const [selectedPeriod, setSelectedPeriod] = useState("*"); - const [selectedPayee, setSelectedPayee] = useState("*"); + const [selectedPeriods, setSelectedPeriods] = useState([]); + const [selectedPayees, setSelectedPayees] = useState([]); 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 ( @@ -179,23 +149,17 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re ) : ( - setSelectedPeriod(v ?? "*")} - disabled={groupOptions.period.length === 0} - renderInput={(params) => } + - setSelectedPayee(v ?? "*")} - disabled={groupOptions.payee.length === 0} - renderInput={(params) => } + Showing {slice.count} transactions across {slice.txns.length} rows @@ -203,78 +167,11 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re - - + + - - {metrics.map((m) => { - const value = metricValue(m.key); - if (value == null) return null; - return ( - - - {m.label} - - - {value} - - - ); - })} - - - {selectedPeriod === "*" && selectedPayee === "*" && pivot.rows.length > 0 && ( - - - - - Period - {pivot.payees.map((payee) => ( - - {payee} - - ))} - - Total - - - - - {pivot.rows.map((row) => ( - - {row.period} - {row.cells.map((cell) => ( - - {cell.sum === 0 ? "—" : formatCurrency(cell.sum, slice.currency)} - - ))} - - {formatCurrency(row.periodSum, slice.currency)} - - - ))} - - Total - {pivot.totals.map((t) => ( - - {formatCurrency(t.sum, slice.currency)} - - ))} - - {formatCurrency(pivot.rows.reduce((s, r) => s + r.periodSum, 0), slice.currency)} - - - -
-
- )} - {slice.txns.length === 0 ? ( @@ -282,7 +179,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re ) : fields ? ( - + ) : null}
)} diff --git a/src/Reports/types.ts b/src/Reports/types.ts index ebbee8b..c9e918e 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -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; diff --git a/src/common/components/OptionMultiSelect.tsx b/src/common/components/OptionMultiSelect.tsx new file mode 100644 index 0000000..0a4d23b --- /dev/null +++ b/src/common/components/OptionMultiSelect.tsx @@ -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 ( + onChange(newVal)} + disabled={options.length === 0} + renderOption={(props, option, { selected }) => ( +
  • + {selected ? : } + {option} +
  • + )} + renderTags={(tagValue, getTagProps) => { + const maxChips = 1; + return ( + <> + {tagValue.slice(0, maxChips).map((tag, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return 12 ? `${tag.slice(0, 10)}..` : tag} size="small" />; + })} + {tagValue.length > maxChips && } + + ); + }} + renderInput={(params) => ( + + )} + /> + ); +} \ No newline at end of file diff --git a/src/common/components/TransactionList.tsx b/src/common/components/TransactionList.tsx index c9e0b38..b6898a1 100644 --- a/src/common/components/TransactionList.tsx +++ b/src/common/components/TransactionList.tsx @@ -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 ( + + {rows.map((row) => ( + + + {row.label} + + + {row.value} + + + ))} + + ); +} + +export function TransactionList({ items, fields, granularity = "monthly", showMetrics = false }: TransactionListProps) { const [activeMonth, setActiveMonth] = useState(null); const [openMonth, setOpenMonth] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); @@ -160,6 +189,7 @@ export function TransactionList({ items, fields, granularity = "monthly" }: Tran + {showMetrics && } {groupByDate(group.items).map((dateGroup) => ( 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, + }; } \ No newline at end of file