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