Reports frontend — spec-driven generate form, sliceable viewer, dimension bars #16
@@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
@@ -18,6 +17,7 @@ import type { ExpenseItem, TxnFieldConfigs } from "../types";
|
||||
import { computeTxnMetrics, groupByDate, groupByPeriod } from "../utils/transactions";
|
||||
import type { PeriodGranularity } from "../utils/transactions";
|
||||
import { TransactionRow } from "./TransactionRow";
|
||||
import { StatCard } from "./StatCard";
|
||||
|
||||
interface TransactionListProps {
|
||||
items: ExpenseItem[];
|
||||
@@ -30,24 +30,21 @@ function GroupMetrics({ items, currency }: { items: ExpenseItem[]; currency: str
|
||||
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 ?? "—" },
|
||||
{
|
||||
label: "Cadence",
|
||||
value:
|
||||
m.cadenceDays == null
|
||||
? "—"
|
||||
: `${Number.isInteger(m.cadenceDays) ? m.cadenceDays : m.cadenceDays.toFixed(2)} days`,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", width: "100%" }}>
|
||||
{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>
|
||||
<StatCard key={row.label} label={row.label} value={row.value} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
@@ -190,9 +187,7 @@ export function TransactionList({ items, fields, granularity = "monthly", showMe
|
||||
{formatCurrency(group.income, group.currency)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "stretch", gap: 1.5, minWidth: 0 }}>
|
||||
{showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
|
||||
</Box>
|
||||
{showMetrics && <GroupMetrics items={group.items} currency={group.currency} />}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 1.5, pt: 1 }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
|
||||
@@ -114,27 +114,36 @@ export interface TxnMetrics {
|
||||
avg: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
firstDate: string | null;
|
||||
lastDate: string | null;
|
||||
cadenceDays: number | 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);
|
||||
const empty = { sum: 0, count: 0, avg: null, min: null, max: null, cadenceDays: null };
|
||||
if (amounts.length === 0) {
|
||||
return { sum: 0, count: 0, avg: null, min: null, max: null, firstDate: null, lastDate: null };
|
||||
return empty;
|
||||
}
|
||||
const sorted = [...items].sort(
|
||||
(a, b) => parseOccurredAt(a.occurred_at).getTime() - parseOccurredAt(b.occurred_at).getTime(),
|
||||
);
|
||||
const dates = items
|
||||
.map((it) => it.occurred_at)
|
||||
.filter((d): d is string => !!d)
|
||||
.map((d) => parseOccurredAt(d).getTime())
|
||||
.sort((a, b) => a - b);
|
||||
const sum = amounts.reduce((s, a) => s + a, 0);
|
||||
let cadenceDays: number | null = null;
|
||||
if (dates.length >= 2) {
|
||||
const gaps: number[] = [];
|
||||
for (let i = 0; i < dates.length - 1; i += 1) {
|
||||
gaps.push((dates[i + 1] - dates[i]) / 86400000);
|
||||
}
|
||||
cadenceDays = Math.round((gaps.reduce((s, g) => s + g, 0) / gaps.length) * 100) / 100;
|
||||
}
|
||||
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,
|
||||
cadenceDays,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user