From 6b340d89f64ec445bad94a516ba482d3eacb759f Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 20 Aug 2026 19:52:43 +0530 Subject: [PATCH] refactor: revamp for older report logic with benefits of newer one --- react-openapi/src/hooks/useApi.ts | 19 ++ src/Reports/GenerateReportPanel.tsx | 292 +++++++++++++++++----------- src/Reports/Report.tsx | 26 ++- src/Reports/ReportList.tsx | 42 ++-- src/Reports/ReportViewer.tsx | 279 +++++++++++++++++--------- src/Reports/types.ts | 192 ++++++++---------- 6 files changed, 502 insertions(+), 348 deletions(-) diff --git a/react-openapi/src/hooks/useApi.ts b/react-openapi/src/hooks/useApi.ts index 7d7b5b4..c201b5b 100644 --- a/react-openapi/src/hooks/useApi.ts +++ b/react-openapi/src/hooks/useApi.ts @@ -3,6 +3,24 @@ import axios, { AxiosInstance } from "axios"; let apiClient: AxiosInstance | null = null; let _onUnauthorized: (() => void) | undefined; +function serializeParams(params: Record): 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) => { diff --git a/src/Reports/GenerateReportPanel.tsx b/src/Reports/GenerateReportPanel.tsx index c634d05..8f596be 100644 --- a/src/Reports/GenerateReportPanel.tsx +++ b/src/Reports/GenerateReportPanel.tsx @@ -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([{ id: 1, group_type: "monthly", group_value: "*" }]); + const [name, setName] = useState(""); + const [granularities, setGranularities] = useState(ALL_GRANULARITIES); + const [groupDims, setGroupDims] = useState(["payee", "tag"]); + const [flow, setFlow] = useState("both"); + const [accounts, setAccounts] = useState([]); + 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(null); - const [entityNames, setEntityNames] = useState([]); + const [accountOptions, setAccountOptions] = useState([]); 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) => - 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 = { 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 = { + 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 - 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. {error && ( @@ -128,81 +145,128 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) { )} - - {rows.map((row) => ( - - - Type - - - updateRow(row.id, { group_value: newVal ?? "" })} - renderInput={(params) => ( - - )} - /> - removeRow(row.id)} - sx={{ mt: 0.25 }} - > - - + + setName(e.target.value)} + sx={{ maxWidth: 420 }} + /> + + + + Granularities + + + {granularityChoices.map((g) => ( + toggle(granularities, g, setGranularities)} + /> + ))} - ))} - + - + + + Group dimensions + + + {dimChoices.map((d) => ( + toggle(groupDims, d, setGroupDims)} + /> + ))} + + - - + + Flow + + + setIgnoreSelf(e.target.checked)} />} + label="Ignore self-transfers" + sx={{ mt: 0.25 }} + /> + + + setStartDate(e.target.value)} - error={Boolean(dateErrors.start)} - helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"} - sx={{ width: 200 }} - /> - 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) => ( + + )} + sx={{ maxWidth: 420 }} /> + + + setStartDate(e.target.value)} + error={Boolean(dateErrors.start)} + helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"} + sx={{ width: 200 }} + /> + setEndDate(e.target.value)} + error={Boolean(dateErrors.end)} + helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"} + sx={{ width: 200 }} + /> + + + + setMinAmount(e.target.value)} + sx={{ width: 200 }} + /> + setMaxAmount(e.target.value)} + sx={{ width: 200 }} + /> + - diff --git a/src/Reports/Report.tsx b/src/Reports/Report.tsx index d8426b0..0f45bdc 100644 --- a/src/Reports/Report.tsx +++ b/src/Reports/Report.tsx @@ -11,7 +11,7 @@ import { } 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"; @@ -45,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 = { 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) { @@ -86,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(); + for (const r of rows) for (const g of r.query?.granularities ?? []) granularities.add(g); + return { count: rows.length, granularities: [...granularities].join(", ") }; }, [reports]); return ( @@ -96,7 +93,7 @@ export default function Report() { {error && ( @@ -107,8 +104,7 @@ export default function Report() { - - + @@ -154,7 +150,7 @@ export default function Report() { } 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)." + description="Generate your first snapshot above — choose granularities and grouping dimensions, then slice the cached cube by period, payee and tag." /> ) : ( diff --git a/src/Reports/ReportList.tsx b/src/Reports/ReportList.tsx index 3813d5c..ad264cf 100644 --- a/src/Reports/ReportList.tsx +++ b/src/Reports/ReportList.tsx @@ -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 ( {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 ( - {report.group_label} + {report.name || report.id} - {fields && ( - - )} - {report.period_label} + {granularities} / - {report.payee} + {dims} {range && ( @@ -73,14 +74,13 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg - {report.entity_count ?? 0} entities - - - {report.txn_count ?? 0} txns - - - {report.expense_count ?? 0} expenses + {accounts} + {amounts && ( + + {amounts} + + )} {fields && ( @@ -89,12 +89,6 @@ export function ReportList({ reports, loading, fields, selectedId, onView, onReg - - - {sum != null ? formatCurrency(sum, "INR") : "—"} - - - + + + + {granularityOptions.map((g: string) => ( + setGranularity(g)} + /> + ))} + + + Flow + + - ) : ( - - - - - - - - - - Showing {slice.count} transactions across {slice.txns.length} rows + + + + + + + + + + + + + {slice.txns.length} transactions · {slice.count} rows + + + + + + + + + + + {bars.length === 0 ? ( + + + No data for this slice. Try another granularity, period or payer. - + + ) : ( + + {bars.map((b) => ( + + + {b.periodId} + + + + {formatCurrency(b.sum, slice.currency)} + + + {b.count} txn{b.count === 1 ? "" : "s"} + + + ))} + + )} - - - - - - - {slice.txns.length === 0 ? ( - - - No transactions in this slice. - - - ) : fields ? ( - - ) : null} - - )} + {slice.txns.length === 0 ? null : fields ? ( + + ) : null} + ); } \ No newline at end of file diff --git a/src/Reports/types.ts b/src/Reports/types.ts index c9e918e..9696dbd 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -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; - txns?: any[]; -} - -export interface SliceFilter { - periods?: string[]; - payees?: string[]; -} export interface SliceSummary { sum: number; @@ -33,12 +14,23 @@ export interface SliceSummary { currency: string; } +export interface SliceFilter { + granularity: string; + periods?: string[]; + payees?: string[]; + tags?: string[]; +} + +export interface PeriodSlice { + periodId: string; + sum: number; + count: number; + firstDate: string | null; + lastDate: string | null; +} + export interface ReportFieldConfigs { - groupLabel: FieldConfig; - granularity: FieldConfig; - periodLabel: FieldConfig; - payee: FieldConfig; - entityCount: FieldConfig; + name: FieldConfig; generatedAt: FieldConfig; } @@ -48,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; @@ -76,15 +51,16 @@ export function apiErrorMessage(e: any): string { return e?.message ?? "Request failed"; } -export function groupTypeEnum(schemas: Record): string[] { - return schemas?.GroupSpec?.properties?.group_type?.enum ?? []; +export function granularityOptions(schemas: Record): string[] { + return schemas?.ReportQuery?.properties?.granularities?.items?.enum ?? []; } -export function groupValueFk(schemas: Record): { 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[] { + return schemas?.ReportQuery?.properties?.group_dims?.items?.enum ?? ["payee", "tag"]; } +export const FLOW_OPTIONS = ["both", "inflows", "outflows"]; + export function metricLabels(schemas: Record): MetricLabel[] { const props: Record = schemas?.ReportMetrics?.properties ?? {}; return Object.entries(props) @@ -97,42 +73,47 @@ export function metricLabels(schemas: Record): 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.periods && filter.periods.length > 0) { - if (!key.period || !filter.periods.includes(key.period.label)) return false; - } - if (filter.payees && filter.payees.length > 0) { - if (!key.payee || !filter.payees.includes(key.payee.label)) 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(); + 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; @@ -145,30 +126,33 @@ export function aggregateSlice(groups: ReportGroupLike[], filter: SliceFilter): const txns: any[] = []; const seen = new Set(); - 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; } } @@ -179,12 +163,8 @@ export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFiel 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 }; } \ No newline at end of file