5 Commits

Author SHA1 Message Date
6b340d89f6 refactor: revamp for older report logic with benefits of newer one 2026-08-20 19:52:43 +05:30
79808d6d3e feat: redesign per-group stats strip in accordion summary
Replace the dated First/Last pills with a Cadence metric computed from
consecutive transaction dates (mirrors backend ReportMetrics.cadence_days)
and render the group stats as spaced StatCards (Sum/Avg/Min/Max/Cadence)
in the collapsed accordion header, matching the Outflows/Inflows layout.
2026-08-20 17:10:48 +05:30
f865f7dec2 feat: show per-group report stats in collapsed accordion header
Move the Sum/Count/Avg/Min/Max/First/Last metric row from inside the
expanded accordion details into the accordion summary as a second row,
so group stats are visible while the group is collapsed. Second row
renders compact label-value pairs instead of pills; summary content is
now a two-row column.
2026-08-20 16:37:05 +05:30
fc3a48a367 refactor: reuse FkMultiSelectField for report period/payee selectors
Export FkMultiSelectField from react-openapi and use it directly in the
report viewer for the Period/Payee dimension filters instead of the
custom OptionMultiSelect component, gaining the admin panel's QoL
behavior (no close-on-click, selected-first with tick marks, chips).
Feed it fabricated FieldConfigs plus fkOptions derived from the report's
metadata.group_options; delete the standalone OptionMultiSelect.
2026-08-20 16:20:06 +05:30
f9759e3968 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.
2026-08-20 16:03:12 +05:30
10 changed files with 692 additions and 533 deletions

View File

@@ -6,6 +6,7 @@ export { useResource } from "./src/context/useResource";
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField";
export { FkMultiSelectField } from "./src/components/fields/renderers/FkMultiSelectField";
export { SseStreamView } from "./src/components/SseStreamView";
export { SseConnectionStatus } from "./src/components/SseConnectionStatus";
export { getApi } from "./src/hooks/useApi";

View File

@@ -3,6 +3,24 @@ import axios, { AxiosInstance } from "axios";
let apiClient: AxiosInstance | null = null;
let _onUnauthorized: (() => void) | undefined;
function serializeParams(params: Record<string, any>): string {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params ?? {})) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
for (const item of value) searchParams.append(key, String(item));
} else if (typeof value === "object") {
for (const [nestedKey, nestedValue] of Object.entries(value)) {
if (nestedValue === undefined || nestedValue === null) continue;
searchParams.append(`${key}[${nestedKey}]`, String(nestedValue));
}
} else {
searchParams.append(key, String(value));
}
}
return searchParams.toString();
}
export function initApi(baseUrl: string, getToken?: () => string | null, onUnauthorized?: () => void): AxiosInstance {
if (apiClient && apiClient.defaults.baseURL === baseUrl) {
_onUnauthorized = onUnauthorized;
@@ -14,6 +32,7 @@ export function initApi(baseUrl: string, getToken?: () => string | null, onUnaut
apiClient = axios.create({
baseURL: baseUrl,
headers: { "Content-Type": "application/json" },
paramsSerializer: serializeParams,
});
apiClient.interceptors.request.use((config) => {

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

@@ -5,78 +5,73 @@ import {
Typography,
TextField,
Button,
IconButton,
MenuItem,
Select,
FormControl,
InputLabel,
Autocomplete,
FormControlLabel,
Checkbox,
Chip,
Alert,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import { useResource, useAppContext, applyDisplayFormat } from "../../react-openapi";
import { useToast } from "../ui/Toast";
import { isDdmmyyyy } from "../common/utils/dates";
import { apiErrorMessage, groupTypeEnum, periodHints } from "./types";
interface GroupRow {
id: number;
group_type: string;
group_value: string;
}
import { apiErrorMessage, granularityOptions, groupDimOptions, FLOW_OPTIONS } from "./types";
interface GenerateReportPanelProps {
onGenerated: (reports: any[]) => void;
onGenerated: (report: any) => void;
}
const ALL_GRANULARITIES = ["weekly", "monthly", "quarterly"];
export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) {
const { schemas, resources } = useAppContext();
const { create } = useResource("reports");
const { list: listEntities } = useResource("entities");
const { list: listAccounts } = useResource("accounts");
const { showToast } = useToast();
const [rows, setRows] = useState<GroupRow[]>([{ id: 1, group_type: "monthly", group_value: "*" }]);
const [name, setName] = useState("");
const [granularities, setGranularities] = useState<string[]>(ALL_GRANULARITIES);
const [groupDims, setGroupDims] = useState<string[]>(["payee", "tag"]);
const [flow, setFlow] = useState("both");
const [accounts, setAccounts] = useState<string[]>([]);
const [ignoreSelf, setIgnoreSelf] = useState(true);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [minAmount, setMinAmount] = useState("");
const [maxAmount, setMaxAmount] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [entityNames, setEntityNames] = useState<string[]>([]);
const [accountOptions, setAccountOptions] = useState<string[]>([]);
const [dateErrors, setDateErrors] = useState<{ start?: string; end?: string }>({});
const types = useMemo(() => groupTypeEnum(schemas), [schemas]);
const entitiesRes = resources.find((r) => r.name === "entities");
const entitiesFormat = entitiesRes?.displayFormat ?? "{name}";
const granularityChoices = useMemo(() => {
const enums = granularityOptions(schemas);
return enums.length ? enums : ALL_GRANULARITIES;
}, [schemas]);
const dimChoices = useMemo(() => groupDimOptions(schemas), [schemas]);
const accountsRes = resources.find((r) => r.name === "accounts");
const accountsFormat = accountsRes?.displayFormat ?? "{name}";
useEffect(() => {
let mounted = true;
listEntities({ limit: 0 }).then((res) => {
listAccounts({ limit: 0 }).then((res) => {
if (!mounted) return;
const names = (res.items ?? [])
.map((it: any) => applyDisplayFormat(it, entitiesFormat))
.map((it: any) => applyDisplayFormat(it, accountsFormat))
.filter((n: string) => n);
setEntityNames([...new Set(names)].sort((a, b) => a.localeCompare(b)));
setAccountOptions([...new Set(names)].sort((a, b) => a.localeCompare(b)));
});
return () => {
mounted = false;
};
}, [listEntities, entitiesFormat]);
}, [listAccounts, accountsFormat]);
const isPayee = (type: string) => type === "payee";
const valueOptions = (row: GroupRow): string[] => ["*", ...(isPayee(row.group_type) ? entityNames : [])];
const valueHint = (row: GroupRow): string =>
isPayee(row.group_type) ? "Entity name, or * for all payees" : `e.g. ${periodHints(row.group_type).join(", ")}, or *`;
const nextRowId = () => Math.max(0, ...rows.map((r) => r.id)) + 1;
const addRow = () => setRows((rs) => [...rs, { id: nextRowId(), group_type: "monthly", group_value: "*" }]);
const removeRow = (id: number) => setRows((rs) => rs.filter((r) => r.id !== id));
const updateRow = (id: number, patch: Partial<GroupRow>) =>
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
const toggle = (list: string[], value: string, setter: (v: string[]) => void) =>
setter(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]);
const validateDates = (): boolean => {
const errs: { start?: string; end?: string } = {};
@@ -86,25 +81,47 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) {
return Object.keys(errs).length === 0;
};
const parseAmount = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
};
const handleSubmit = async () => {
if (!validateDates()) return;
setError(null);
const groups = rows
.filter((r) => r.group_type && r.group_value.trim())
.map(({ group_type, group_value }) => ({ group_type, group_value: group_value.trim() }));
if (groups.length === 0) {
setError("At least one group is required");
const min = parseAmount(minAmount);
const max = parseAmount(maxAmount);
if (minAmount.trim() && min === null) {
setError("Min amount must be a number");
return;
}
setSubmitting(true);
const payload: Record<string, any> = { groups };
if (maxAmount.trim() && max === null) {
setError("Max amount must be a number");
return;
}
if (min !== null && max !== null && min > max) {
setError("Min amount cannot exceed max amount");
return;
}
const payload: Record<string, any> = {
name: name.trim(),
granularities,
group_dims: groupDims,
flow,
ignore_self: ignoreSelf,
};
if (accounts.length) payload.accounts = accounts;
if (startDate.trim()) payload.start_date = startDate.trim();
if (endDate.trim()) payload.end_date = endDate.trim();
if (min !== null) payload.min_amount = min;
if (max !== null) payload.max_amount = max;
setSubmitting(true);
try {
const created = await create(payload);
const list = Array.isArray(created) ? created : created ? [created] : [];
showToast(`Generated ${list.length} report${list.length === 1 ? "" : "s"}`);
onGenerated(list);
showToast(`Generated snapshot ${created?.name ? `${created.name}` : ""}`.trim() || "Generated snapshot");
onGenerated(created);
} catch (e: any) {
setError(apiErrorMessage(e));
} finally {
@@ -118,8 +135,8 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) {
Generate report
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>
Choose the dimensions to snapshot. At least one group is required with no period dimension the server
snapshots weekly, monthly and quarterly.
Define the snapshot's scope. Granularity, payee and tag are sliced at view time the cube is built once and
every combination stays cheap to read.
</Typography>
{error && (
@@ -128,81 +145,128 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) {
</Alert>
)}
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{rows.map((row) => (
<Box key={row.id} sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}>
<FormControl size="small" sx={{ width: 180, flexShrink: 0 }}>
<InputLabel id={`group-type-${row.id}`}>Type</InputLabel>
<Select
labelId={`group-type-${row.id}`}
label="Type"
value={row.group_type}
onChange={(e) => updateRow(row.id, { group_type: e.target.value, group_value: "*" })}
>
{types.map((t) => (
<MenuItem key={t} value={t}>
{t}
</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
freeSolo
size="small"
sx={{ flex: 1, minWidth: 240 }}
options={valueOptions(row)}
value={row.group_value}
onInputChange={(_, newVal) => updateRow(row.id, { group_value: newVal ?? "" })}
renderInput={(params) => (
<TextField {...params} label="Value" helperText={valueHint(row)} />
)}
/>
<IconButton
aria-label="Remove group"
disabled={rows.length === 1}
onClick={() => removeRow(row.id)}
sx={{ mt: 0.25 }}
>
<RemoveCircleOutlineIcon />
</IconButton>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
size="small"
label="Name"
placeholder="e.g. Monthly spending"
value={name}
onChange={(e) => setName(e.target.value)}
sx={{ maxWidth: 420 }}
/>
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Granularities
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{granularityChoices.map((g) => (
<Chip
key={g}
label={g}
clickable
color={granularities.includes(g) ? "primary" : "default"}
variant={granularities.includes(g) ? "filled" : "outlined"}
onClick={() => toggle(granularities, g, setGranularities)}
/>
))}
</Box>
))}
</Box>
</Box>
<Button startIcon={<AddIcon />} size="small" sx={{ mt: 1 }} onClick={addRow}>
Add dimension
</Button>
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Group dimensions
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{dimChoices.map((d) => (
<Chip
key={d}
label={d}
clickable
color={groupDims.includes(d) ? "primary" : "default"}
variant={groupDims.includes(d) ? "filled" : "outlined"}
onClick={() => toggle(groupDims, d, setGroupDims)}
/>
))}
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1.5, mt: 2, flexWrap: "wrap" }}>
<TextField
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", alignItems: "flex-start" }}>
<FormControl size="small" sx={{ width: 200 }}>
<InputLabel id="flow-label">Flow</InputLabel>
<Select labelId="flow-label" label="Flow" value={flow} onChange={(e) => setFlow(e.target.value)}>
{FLOW_OPTIONS.map((f) => (
<MenuItem key={f} value={f}>
{f}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
control={<Checkbox checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
label="Ignore self-transfers"
sx={{ mt: 0.25 }}
/>
</Box>
<Autocomplete
multiple
freeSolo
size="small"
label="Start date"
placeholder="DD-MM-YYYY"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
error={Boolean(dateErrors.start)}
helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"}
sx={{ width: 200 }}
/>
<TextField
size="small"
label="End date"
placeholder="DD-MM-YYYY"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
error={Boolean(dateErrors.end)}
helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"}
sx={{ width: 200 }}
options={accountOptions}
value={accounts}
onChange={(_, newVal) => setAccounts(newVal)}
renderInput={(params) => (
<TextField {...params} label="Accounts" placeholder="Restrict to accounts (empty = all)" />
)}
sx={{ maxWidth: 420 }}
/>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
<TextField
size="small"
label="Start date"
placeholder="DD-MM-YYYY"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
error={Boolean(dateErrors.start)}
helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"}
sx={{ width: 200 }}
/>
<TextField
size="small"
label="End date"
placeholder="DD-MM-YYYY"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
error={Boolean(dateErrors.end)}
helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"}
sx={{ width: 200 }}
/>
</Box>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
<TextField
size="small"
label="Min amount"
placeholder="e.g. 500"
value={minAmount}
onChange={(e) => setMinAmount(e.target.value)}
sx={{ width: 200 }}
/>
<TextField
size="small"
label="Max amount"
placeholder="e.g. 5000"
value={maxAmount}
onChange={(e) => setMaxAmount(e.target.value)}
sx={{ width: 200 }}
/>
</Box>
</Box>
<Box sx={{ mt: 2, display: "flex", justifyContent: "flex-end" }}>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleSubmit}
disabled={submitting}
>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleSubmit} disabled={submitting}>
{submitting ? "Generating…" : "Generate"}
</Button>
</Box>

View File

@@ -1,7 +1,17 @@
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 { useResource, useAppContext } from "../../react-openapi";
import { useToast } from "../ui/Toast";
import { PageHeader } from "../ui/PageHeader";
import { EmptyState } from "../ui/EmptyState";
@@ -35,22 +45,19 @@ export default function Report() {
}, [load]);
const handleGenerated = useCallback(
(created: any[]) => {
(created: any) => {
load();
if (created?.[0]?.id) setSelectedId(created[0].id);
if (created?.id) setSelectedId(created.id);
},
[load],
);
const handleRegenerate = useCallback(
async (report: any) => {
const payload: Record<string, any> = { groups: report.groups ?? [] };
if (report.start_date) payload.start_date = report.start_date;
if (report.end_date) payload.end_date = report.end_date;
try {
await create(payload);
const created = await create(report.query ?? {});
showToast("Report regenerated");
setSelectedId(report.id);
setSelectedId(created?.id ?? report.id);
setViewerVersion((v) => v + 1);
load();
} catch (e: any) {
@@ -76,9 +83,9 @@ export default function Report() {
const summary = useMemo(() => {
const rows = reports ?? [];
const txnCount = rows.reduce((s, r) => s + (r.txn_count ?? 0), 0);
const total = rows.reduce((s, r) => s + (typeof r.metrics?.sum === "number" ? r.metrics.sum : 0), 0);
return { count: rows.length, txnCount, total };
const granularities = new Set<string>();
for (const r of rows) for (const g of r.query?.granularities ?? []) granularities.add(g);
return { count: rows.length, granularities: [...granularities].join(", ") };
}, [reports]);
return (
@@ -86,7 +93,7 @@ export default function Report() {
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Reports" }]}
title="Reports"
subtitle="Generate period/payee snapshots from the reporting API and slice the cached data."
subtitle="Build an immutable snapshot cube once, then slice by granularity, period, payee and tag at view time."
/>
{error && (
@@ -97,37 +104,70 @@ export default function Report() {
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 4 }}>
<StatCard label="Reports" value={summary.count.toLocaleString("en-IN")} />
<StatCard label="Transactions covered" value={summary.txnCount.toLocaleString("en-IN")} />
<StatCard label="Total" value={formatCurrency(summary.total, "INR")} />
<StatCard label="Granularities covered" value={summary.granularities || "—"} />
</Box>
<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 snapshot above — choose granularities and grouping dimensions, then slice the cached cube by period, payee and tag."
/>
</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

@@ -3,7 +3,7 @@ import { Box, Paper, Typography, Button, IconButton, Skeleton, Tooltip } from "@
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import VisibilityIcon from "@mui/icons-material/Visibility";
import CachedIcon from "@mui/icons-material/Cached";
import { ListCellRenderer, formatCurrency } from "../../react-openapi";
import { ListCellRenderer } from "../../react-openapi";
import type { ReportFieldConfigs } from "./types";
interface ReportListProps {
@@ -30,10 +30,14 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{reports.map((report) => {
const sum = typeof report.metrics?.sum === "number" ? report.metrics.sum : null;
const range =
report.start_date || report.end_date
? `range ${report.start_date || "…"}${report.end_date || ""}`
const q = report.query ?? {};
const range = q.start_date || q.end_date ? `range ${q.start_date || "…"}${q.end_date || "…"}` : null;
const dims = Array.isArray(q.group_dims) ? q.group_dims.join(", ") : "";
const granularities = Array.isArray(q.granularities) ? q.granularities.join(", ") : "";
const accounts = Array.isArray(q.accounts) ? `${q.accounts.length} account${q.accounts.length === 1 ? "" : "s"}` : "all accounts";
const amounts =
q.min_amount != null || q.max_amount != null
? `amount ${q.min_amount ?? "0"}${q.max_amount ?? "∞"}`
: null;
return (
<Paper
@@ -50,20 +54,17 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 260px", minWidth: 0 }}>
<Typography variant="body1" fontWeight={600} noWrap sx={{ fontSize: "0.9375rem" }}>
{report.group_label}
{report.name || report.id}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", mt: 0.25 }}>
{fields && (
<ListCellRenderer field={fields.granularity} value={report.granularity} />
)}
<Typography variant="caption" color="text.secondary">
{report.period_label}
{granularities}
</Typography>
<Typography variant="caption" color="text.disabled">
/
</Typography>
<Typography variant="caption" color="text.secondary">
{report.payee}
{dims}
</Typography>
{range && (
<Typography variant="caption" color="text.disabled">
@@ -73,14 +74,13 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg
</Box>
<Box sx={{ display: "flex", gap: 1.5, mt: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{report.entity_count ?? 0} entities
</Typography>
<Typography variant="caption" color="text.secondary">
{report.txn_count ?? 0} txns
</Typography>
<Typography variant="caption" color="text.secondary">
{report.expense_count ?? 0} expenses
{accounts}
</Typography>
{amounts && (
<Typography variant="caption" color="text.secondary">
{amounts}
</Typography>
)}
{fields && (
<Box sx={{ color: "text.secondary" }}>
<ListCellRenderer field={fields.generatedAt} value={report.generated_at} />
@@ -89,12 +89,6 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg
</Box>
</Box>
<Box sx={{ flexShrink: 0, textAlign: "right" }}>
<Typography variant="body1" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{sum != null ? formatCurrency(sum, "INR") : "—"}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 0.5, flexShrink: 0 }}>
<Button size="small" variant="contained" startIcon={<VisibilityIcon />} onClick={() => onView(report.id)}>
View

View File

@@ -1,29 +1,56 @@
import React, { useEffect, useMemo, useState } from "react";
import {
Box,
Paper,
Typography,
Button,
IconButton,
Alert,
Skeleton,
TextField,
Autocomplete,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Box, Paper, Typography, Button, IconButton, Alert, Skeleton, Chip, MenuItem, Select, FormControl, InputLabel } 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 { FkMultiSelectField, useResource, formatCurrency } from "../../react-openapi";
import type { FieldConfig } from "../../react-openapi";
import { StatCard } from "../common/components/StatCard";
import { TransactionList } from "../common/components/TransactionList";
import type { TxnFieldConfigs } from "../common/types";
import { toPeriodGranularity } from "../common/utils/transactions";
import { aggregateSlice, buildPivot, metricLabels } from "./types";
import { aggregateSlice, periodSlices, FLOW_OPTIONS, apiErrorMessage } from "./types";
const periodField: FieldConfig = {
name: "period",
label: "Period",
description: "",
type: "string",
order: 0,
hidden: {},
filterable: true,
sortable: false,
readOnly: false,
required: false,
isArray: true,
};
const payeeField: FieldConfig = {
name: "payee",
label: "Payee",
description: "",
type: "string",
order: 0,
hidden: {},
filterable: true,
sortable: false,
readOnly: false,
required: false,
isArray: true,
};
const tagField: FieldConfig = {
name: "tag",
label: "Tag",
description: "",
type: "string",
order: 0,
hidden: {},
filterable: true,
sortable: false,
readOnly: false,
required: false,
isArray: true,
};
interface ReportViewerProps {
id: string;
@@ -33,31 +60,55 @@ interface ReportViewerProps {
onRegenerated: (report: any) => void;
}
function snapshotGranularities(report: any): string[] {
const fromQuery = report?.query?.granularities;
if (Array.isArray(fromQuery) && fromQuery.length) return fromQuery;
const fromResponse = report?.granularities;
if (Array.isArray(fromResponse) && fromResponse.length) return fromResponse;
const series = report?.buckets?.[0]?.series;
return series ? Object.keys(series) : [];
}
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 [granularity, setGranularity] = useState<string | null>(null);
const [flow, setFlow] = useState("outflows");
const [selectedPeriods, setSelectedPeriods] = useState<string[]>([]);
const [selectedPayees, setSelectedPayees] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const prevGranularity = useRef<string | null>(null);
const params = useMemo(() => {
const p: Record<string, any> = { flow };
if (granularity) p.granularity = [granularity];
if (selectedPeriods.length) p.period_ids = selectedPeriods;
if (selectedPayees.length) p.payee = selectedPayees;
if (selectedTags.length) p.tags = selectedTags;
return p;
}, [granularity, flow, selectedPeriods, selectedPayees, selectedTags]);
useEffect(() => {
let mounted = true;
setLoading(true);
setError(null);
get(id)
const previous = prevGranularity.current;
get(id, params)
.then((res) => {
if (!mounted) return;
setReport(res);
setSelectedPeriod("*");
setSelectedPayee("*");
const options = snapshotGranularities(res);
if (granularity === null && options.length) setGranularity(options[0]);
if (previous !== null && previous !== granularity) setSelectedPeriods([]);
prevGranularity.current = granularity;
})
.catch((e: any) => {
if (!mounted) return;
setError(e?.response?.data?.detail ?? e?.message ?? "Failed to load report");
setError(apiErrorMessage(e));
})
.finally(() => {
if (mounted) setLoading(false);
@@ -65,47 +116,37 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
return () => {
mounted = false;
};
}, [id, version, reload, get]);
}, [id, version, reload, params, get, granularity]);
const data = useMemo(() => (Array.isArray(report?.data) ? report.data : null), [report]);
const granularityOptions = useMemo(() => snapshotGranularities(report), [report]);
const groupOptions = useMemo(() => {
const options = report?.metadata?.group_options;
if (!options || typeof options !== "object") return { period: [], payee: [] };
return {
period: Array.isArray(options.period) ? options.period.map(String) : [],
payee: Array.isArray(options.payee) ? options.payee.map(String) : [],
};
}, [report]);
const slice = useMemo(
() => aggregateSlice(data ?? [], { period: selectedPeriod, payee: selectedPayee }),
[data, selectedPeriod, selectedPayee],
const periodOptions = useMemo(
() => (Array.isArray(report?.period_ids) ? report.period_ids.map((label: string) => ({ value: label, label })) : []),
[report],
);
const payeeOptions = useMemo(
() => (Array.isArray(report?.payees) ? report.payees.map((label: string) => ({ value: label, label })) : []),
[report],
);
const tagOptions = useMemo(
() => (Array.isArray(report?.tags) ? report.tags.map((label: string) => ({ value: label, label })) : []),
[report],
);
const pivot = useMemo(
() => buildPivot(data ?? [], groupOptions.period, groupOptions.payee),
[data, groupOptions],
const filter = useMemo(
() => ({
granularity: granularity ?? granularityOptions[0] ?? "",
periods: selectedPeriods,
payees: selectedPayees,
tags: selectedTags,
}),
[granularity, granularityOptions, selectedPeriods, selectedPayees, selectedTags],
);
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 slice = useMemo(() => aggregateSlice(report?.buckets ?? [], filter), [report, filter]);
const bars = useMemo(() => periodSlices(report?.buckets ?? [], filter), [report, filter]);
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) {
if (loading && !report) {
return (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
<Skeleton variant="text" width={220} height={28} />
@@ -130,6 +171,13 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
if (!report) return null;
const activeGranularity = granularity ?? report.granularities?.[0] ?? "";
const maxBar = bars.reduce((m, b) => Math.max(m, b.sum), 0);
const range =
report.query?.start_date || report.query?.end_date
? `range ${report.query.start_date || "…"}${report.query.end_date || "…"}`
: null;
return (
<Paper variant="outlined" sx={{ borderRadius: 3, overflow: "hidden" }}>
<Box
@@ -146,146 +194,137 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{report.group_label}
{report.name}
</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
<Typography variant="caption" color="text.secondary">
{report.granularity} · {report.period_label} · {report.payee}
flow {report.flow} · generated {report.generated_at ?? report.created_at}
</Typography>
<Typography variant="caption" color="text.disabled">
{report.entity_count ?? 0} entities · {report.txn_count ?? 0} txns · {report.expense_count ?? 0} expenses
</Typography>
{report.start_date || report.end_date ? (
{range && (
<Typography variant="caption" color="text.disabled">
range {report.start_date || "…"} {report.end_date || "…"}
{range}
</Typography>
) : null}
)}
<Typography variant="caption" color="text.disabled">
{report.payees?.length ?? 0} payees · {report.tags?.length ?? 0} tags
</Typography>
</Box>
</Box>
<IconButton aria-label="Regenerate report" onClick={() => onRegenerated(report)}>
<CachedIcon />
</IconButton>
<IconButton aria-label="Close report" onClick={onClose}>
<CloseIcon />
</IconButton>
</Box>
{data === null ? (
<Box sx={{ p: 3 }}>
<Alert severity="info" sx={{ borderRadius: 2, mb: 2 }}>
The cached data for this report has expired. Regenerate it to rebuild the snapshot.
</Alert>
<Button variant="contained" startIcon={<CachedIcon />} onClick={() => onRegenerated(report)}>
Regenerate
</Button>
<Box sx={{ p: 2.5 }}>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", alignItems: "center", mb: 2 }}>
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{granularityOptions.map((g: string) => (
<Chip
key={g}
label={g}
clickable
size="small"
color={activeGranularity === g ? "primary" : "default"}
variant={activeGranularity === g ? "filled" : "outlined"}
onClick={() => setGranularity(g)}
/>
))}
</Box>
<FormControl size="small" sx={{ width: 140 }}>
<InputLabel id="viewer-flow-label">Flow</InputLabel>
<Select labelId="viewer-flow-label" label="Flow" value={flow} onChange={(e) => setFlow(e.target.value)}>
{FLOW_OPTIONS.map((f) => (
<MenuItem key={f} value={f}>
{f}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
) : (
<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" />}
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap", mb: 2.5 }}>
<Box sx={{ width: 260 }}>
<FkMultiSelectField
field={{ ...periodField, readOnly: periodOptions.length === 0 }}
fkOptions={periodOptions}
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" />}
</Box>
<Box sx={{ width: 260 }}>
<FkMultiSelectField
field={{ ...payeeField, readOnly: payeeOptions.length === 0 }}
fkOptions={payeeOptions}
value={selectedPayees}
onChange={setSelectedPayees}
/>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: "center" }}>
Showing {slice.count} transactions across {slice.txns.length} rows
</Box>
<Box sx={{ width: 260 }}>
<FkMultiSelectField
field={{ ...tagField, readOnly: tagOptions.length === 0 }}
fkOptions={tagOptions}
value={selectedTags}
onChange={setSelectedTags}
/>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ alignSelf: "center" }}>
{slice.txns.length} transactions · {slice.count} rows
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mb: 2.5 }}>
<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="Net" value={formatCurrency(slice.income - slice.spent, slice.currency)} color="info.main" />
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
</Box>
{bars.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mb: 2.5 }}>
<Typography variant="body2" color="text.secondary">
No data for this slice. Try another granularity, period or payer.
</Typography>
</Box>
</Paper>
) : (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 2, mb: 2.5 }}>
{bars.map((b) => (
<Box key={b.periodId} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
{b.periodId}
</Typography>
<Box
sx={{
height: 20,
borderRadius: 1,
bgcolor: flow === "outflows" ? "error.main" : "success.main",
opacity: 0.85,
minWidth: 4,
}}
style={{ width: `${maxBar ? Math.max((b.sum / maxBar) * 100, 2) : 2}%` }}
/>
<Typography variant="body2" fontWeight={600}>
{formatCurrency(b.sum, slice.currency)}
</Typography>
<Typography variant="caption" color="text.disabled">
{b.count} txn{b.count === 1 ? "" : "s"}
</Typography>
</Box>
))}
</Paper>
)}
<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="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">
No transactions in this slice.
</Typography>
</Paper>
) : fields ? (
<TransactionList items={slice.txns} fields={fields} granularity={toPeriodGranularity(report.granularity)} />
) : null}
</Box>
)}
{slice.txns.length === 0 ? null : fields ? (
<TransactionList
items={slice.txns}
fields={fields}
granularity={toPeriodGranularity(activeGranularity)}
showMetrics
/>
) : null}
</Box>
</Paper>
);
}

View File

@@ -1,23 +1,4 @@
import type { FieldConfig, ResourceConfig } from "../../react-openapi";
import { parseDdmmyyyy } from "../common/utils/dates";
export interface ParsedGroupKey {
period?: { granularity: string; label: string };
payee?: { label: string };
range?: { start?: string; end?: string };
}
export interface ReportGroupLike {
key: string;
group_label?: string;
metrics?: Record<string, any>;
txns?: any[];
}
export interface SliceFilter {
period?: string;
payee?: string;
}
export interface SliceSummary {
sum: number;
@@ -33,31 +14,23 @@ export interface SliceSummary {
currency: string;
}
export interface PivotCell {
payee: string;
export interface SliceFilter {
granularity: string;
periods?: string[];
payees?: string[];
tags?: string[];
}
export interface PeriodSlice {
periodId: 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[];
firstDate: string | null;
lastDate: string | null;
}
export interface ReportFieldConfigs {
groupLabel: FieldConfig;
granularity: FieldConfig;
periodLabel: FieldConfig;
payee: FieldConfig;
entityCount: FieldConfig;
name: FieldConfig;
generatedAt: FieldConfig;
}
@@ -67,23 +40,6 @@ export interface MetricLabel {
order: number;
}
/** Split a concrete cache key into its dimension parts, e.g. `period:monthly:2026-Jan|payee:Zepto`. */
export function parseCacheKey(key: string): ParsedGroupKey {
const out: ParsedGroupKey = {};
for (const dim of key.split("|")) {
if (!dim) continue;
const [name, ...rest] = dim.split(":");
if (name === "period" && rest.length >= 2) {
out.period = { granularity: rest[0], label: rest.slice(1).join(":") };
} else if (name === "payee" && rest.length >= 1) {
out.payee = { label: rest.join(":") };
} else if (name === "range" && rest.length >= 1) {
out.range = { start: rest[0] || undefined, end: rest[1] || undefined };
}
}
return out;
}
export function apiErrorMessage(e: any): string {
if (e?.response?.data) {
const d = e.response.data;
@@ -95,15 +51,16 @@ export function apiErrorMessage(e: any): string {
return e?.message ?? "Request failed";
}
export function groupTypeEnum(schemas: Record<string, any>): string[] {
return schemas?.GroupSpec?.properties?.group_type?.enum ?? [];
export function granularityOptions(schemas: Record<string, any>): string[] {
return schemas?.ReportQuery?.properties?.granularities?.items?.enum ?? [];
}
export function groupValueFk(schemas: Record<string, any>): { resource?: string; prefetch?: boolean } | null {
const fk = schemas?.GroupSpec?.properties?.group_value?.["x-fk"];
return fk && typeof fk === "object" ? fk : null;
export function groupDimOptions(schemas: Record<string, any>): string[] {
return schemas?.ReportQuery?.properties?.group_dims?.items?.enum ?? ["payee", "tag"];
}
export const FLOW_OPTIONS = ["both", "inflows", "outflows"];
export function metricLabels(schemas: Record<string, any>): MetricLabel[] {
const props: Record<string, any> = schemas?.ReportMetrics?.properties ?? {};
return Object.entries(props)
@@ -116,42 +73,47 @@ export function metricLabels(schemas: Record<string, any>): MetricLabel[] {
.sort((a, b) => a.order - b.order);
}
export function periodHints(granularity: string): string[] {
const y = new Date().getFullYear();
switch (granularity) {
case "weekly":
return [`${y}-W01`, `${y}-W26`];
case "monthly":
return [`${y}-Jan`, `${y}-Feb`];
case "quarterly":
return [`${y}-Jan-Mar`, `${y}-Apr-Jun`];
case "yearly":
return [`${y - 1}`, `${y}`];
default:
return [`${y}-Jan`, `${y}-W01`, `${y}-Jan-Mar`, `${y}`];
}
}
function dateVal(value: string): number {
try {
return parseDdmmyyyy(value).getTime();
} catch {
return 0;
}
const t = new Date(value).getTime();
return Number.isNaN(t) ? 0 : t;
}
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.payee && filter.payee !== "*") {
if (!key.payee || key.payee.label !== filter.payee) return false;
}
function bucketMatches(bucket: any, filter: SliceFilter): boolean {
const gk = bucket?.group_key ?? {};
if (filter.payees?.length && !(gk.payee ?? []).some((p: string) => filter.payees?.includes(p))) return false;
if (filter.tags?.length && !(gk.tag ?? []).some((t: string) => filter.tags?.includes(t))) return false;
return true;
}
export function aggregateSlice(groups: ReportGroupLike[], filter: SliceFilter): SliceSummary {
export function periodSlices(buckets: any[], filter: SliceFilter): PeriodSlice[] {
const byPeriod = new Map<string, PeriodSlice>();
for (const bucket of buckets ?? []) {
if (!bucketMatches(bucket, filter)) continue;
for (const period of bucket.series?.[filter.granularity] ?? []) {
if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue;
const m = period.metrics ?? {};
const cur = byPeriod.get(period.period_id) ?? {
periodId: period.period_id,
sum: 0,
count: 0,
firstDate: null,
lastDate: null,
};
cur.sum += typeof m.sum === "number" ? m.sum : 0;
cur.count += typeof m.count === "number" ? m.count : 0;
if (m.first_date && (!cur.firstDate || dateVal(String(m.first_date)) < dateVal(cur.firstDate))) {
cur.firstDate = String(m.first_date);
}
if (m.last_date && (!cur.lastDate || dateVal(String(m.last_date)) > dateVal(cur.lastDate))) {
cur.lastDate = String(m.last_date);
}
byPeriod.set(period.period_id, cur);
}
}
return [...byPeriod.values()];
}
export function aggregateSlice(buckets: any[], filter: SliceFilter): SliceSummary {
let sum = 0;
let count = 0;
let spent = 0;
@@ -164,75 +126,45 @@ export function aggregateSlice(groups: ReportGroupLike[], filter: SliceFilter):
const txns: any[] = [];
const seen = new Set<string>();
for (const group of groups) {
if (!groupMatches(group, filter)) continue;
const m = group.metrics ?? {};
if (typeof m.sum === "number") sum += m.sum;
if (typeof m.count === "number") count += m.count;
if (typeof m.min === "number") min = min === null ? m.min : Math.min(min, m.min);
if (typeof m.max === "number") max = max === null ? m.max : Math.max(max, m.max);
if (m.first_date && (!firstDate || dateVal(String(m.first_date)) < dateVal(firstDate))) {
firstDate = String(m.first_date);
}
if (m.last_date && (!lastDate || dateVal(String(m.last_date)) > dateVal(lastDate))) {
lastDate = String(m.last_date);
}
for (const txn of group.txns ?? []) {
if (txn?.id != null) {
if (seen.has(txn.id)) continue;
seen.add(txn.id);
for (const bucket of buckets ?? []) {
if (!bucketMatches(bucket, filter)) continue;
for (const period of bucket.series?.[filter.granularity] ?? []) {
if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue;
const m = period.metrics ?? {};
if (typeof m.sum === "number") sum += m.sum;
if (typeof m.count === "number") count += m.count;
if (typeof m.min === "number") min = min === null ? m.min : Math.min(min, m.min);
if (typeof m.max === "number") max = max === null ? m.max : Math.max(max, m.max);
if (m.first_date && (!firstDate || dateVal(String(m.first_date)) < dateVal(firstDate))) {
firstDate = String(m.first_date);
}
if (m.last_date && (!lastDate || dateVal(String(m.last_date)) > dateVal(lastDate))) {
lastDate = String(m.last_date);
}
for (const txn of period.txns ?? []) {
if (txn?.id != null) {
if (seen.has(txn.id)) continue;
seen.add(txn.id);
}
txns.push(txn);
const amt = Number(txn?.amount ?? 0);
if (amt < 0) spent += Math.abs(amt);
else income += amt;
const c = txn?.account?.currency;
if (c) currency = c;
}
txns.push(txn);
const amt = Number(txn?.amount ?? 0);
if (amt < 0) spent += Math.abs(amt);
else income += amt;
const c = txn?.account?.currency;
if (c) currency = c;
}
}
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;
const find = (name: string) => reportsRes.fields.find((f) => f.name === name);
const groupLabel = find("group_label");
const granularity = find("granularity");
const periodLabel = find("period_label");
const payee = find("payee");
const entityCount = find("entity_count");
const name = find("name");
const generatedAt = find("generated_at");
if (!groupLabel || !granularity || !periodLabel || !payee || !entityCount || !generatedAt) return null;
return { groupLabel, granularity, periodLabel, payee, entityCount, generatedAt };
if (!name || !generatedAt) return null;
return { name, generatedAt };
}

View File

@@ -14,17 +14,43 @@ 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";
import { StatCard } from "./StatCard";
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: "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: "Cadence",
value:
m.cadenceDays == null
? "—"
: `${Number.isInteger(m.cadenceDays) ? m.cadenceDays : m.cadenceDays.toFixed(2)} days`,
},
];
return (
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", width: "100%" }}>
{rows.map((row) => (
<StatCard key={row.label} label={row.label} value={row.value} />
))}
</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);
@@ -136,28 +162,32 @@ export function TransactionList({ items, fields, granularity = "monthly" }: Tran
px: 2,
py: 1,
"& .MuiAccordionSummary-content": {
alignItems: "center",
gap: 1.5,
flexDirection: "column",
alignItems: "stretch",
gap: 0.75,
minWidth: 0,
},
}}
>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{group.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="body2" fontWeight={700} color="error.main">
{formatCurrency(group.spent, group.currency)}
</Typography>
<Typography variant="body2" color="text.disabled">
/
</Typography>
<Typography variant="body2" fontWeight={700} color="success.main">
{formatCurrency(group.income, group.currency)}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, minWidth: 0 }}>
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
{group.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
</Typography>
<Box sx={{ flex: 1 }} />
<Typography variant="body2" fontWeight={700} color="error.main">
{formatCurrency(group.spent, group.currency)}
</Typography>
<Typography variant="body2" color="text.disabled">
/
</Typography>
<Typography variant="body2" fontWeight={700} color="success.main">
{formatCurrency(group.income, group.currency)}
</Typography>
</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 }}>

View File

@@ -106,4 +106,44 @@ 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;
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 empty;
}
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),
cadenceDays,
};
}