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:
@@ -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} />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
55
src/common/components/OptionMultiSelect.tsx
Normal file
55
src/common/components/OptionMultiSelect.tsx
Normal 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} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user