diff --git a/src/Reports/ReportViewer.tsx b/src/Reports/ReportViewer.tsx index f744dba..ed165f5 100644 --- a/src/Reports/ReportViewer.tsx +++ b/src/Reports/ReportViewer.tsx @@ -1,14 +1,19 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; +import React, { useCallback, 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 KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; 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 type { ListPeriodGroup } from "../common/utils/transactions"; -import { buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types"; +import { buildDimensionBars, buildPeriodGroups, sliceSummary, FLOW_OPTIONS, apiErrorMessage } from "./types"; +import type { ReportDim } from "./types"; + +const MAX_VISIBLE_BARS = 10; const periodField: FieldConfig = { name: "period", @@ -78,6 +83,7 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re const [reload, setReload] = useState(0); const [granularity, setGranularity] = useState(null); const [flow, setFlow] = useState("outflows"); + const [dim, setDim] = useState("period"); const [selectedPeriods, setSelectedPeriods] = useState([]); const [selectedPayees, setSelectedPayees] = useState([]); const [selectedTags, setSelectedTags] = useState([]); @@ -145,6 +151,8 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re const periodGroups = useMemo(() => buildPeriodGroups(report?.buckets ?? [], filter), [report, filter]); const slice = useMemo(() => sliceSummary(periodGroups), [periodGroups]); + const bars = useMemo(() => buildDimensionBars(report?.buckets ?? [], filter, dim), [report, filter, dim]); + const visibleBars = useMemo(() => bars.slice(0, MAX_VISIBLE_BARS), [bars]); const listGroups = useMemo( () => periodGroups.map((g) => ({ @@ -167,6 +175,32 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re [periodGroups], ); + const barsListRef = useRef(null); + const [barScroll, setBarScroll] = useState({ canUp: false, canDown: false, overflow: false }); + + const updateBarScroll = useCallback(() => { + const el = barsListRef.current; + if (!el) return; + const overflow = el.scrollHeight > el.clientHeight + 2; + setBarScroll({ + overflow, + canUp: overflow && el.scrollTop > 2, + canDown: overflow && el.scrollTop < el.scrollHeight - el.clientHeight - 2, + }); + }, []); + + useEffect(() => { + const el = barsListRef.current; + if (el) el.scrollTop = 0; + updateBarScroll(); + }, [dim, visibleBars.length, updateBarScroll]); + + const scrollBarsByPage = useCallback((dir: number) => { + const el = barsListRef.current; + if (!el) return; + el.scrollTo({ top: dir > 0 ? el.scrollHeight : 0, behavior: "smooth" }); + }, []); + if (loading && !report) { return ( @@ -193,7 +227,16 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re if (!report) return null; const activeGranularity = granularity ?? report.granularities?.[0] ?? ""; - const maxBar = periodGroups.reduce((m, g) => Math.max(m, g.metrics.sum), 0); + const maxBar = bars.reduce((m, b) => Math.max(m, Math.abs(b.sum)), 0); + const dimOptions: { id: ReportDim; label: string; count: number }[] = [ + { id: "period", label: "Period", count: report.period_ids?.length ?? 0 }, + { id: "payee", label: "Payees", count: report.payees?.length ?? 0 }, + { id: "tag", label: "Tags", count: report.tags?.length ?? 0 }, + ]; + const activeDimIndex = Math.max( + 0, + dimOptions.findIndex((d) => d.id === dim), + ); const range = report.query?.start_date || report.query?.end_date ? `range ${report.query.start_date || "…"} → ${report.query.end_date || "…"}` @@ -303,39 +346,148 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re - {periodGroups.length === 0 ? ( - - - No data for this slice. Try another granularity, period or payer. - - - ) : ( - - {periodGroups.map((g) => ( - - - {g.key} - + + + + {dimOptions.map((d) => { + const active = d.id === dim; + return ( setDim(d.id)} sx={{ - height: 20, - borderRadius: 1, - bgcolor: flow === "outflows" ? "error.main" : "success.main", - opacity: 0.85, - minWidth: 4, + position: "relative", + zIndex: 1, + flex: 1, + border: "none", + background: "transparent", + px: 3, + py: 1, + borderRadius: "999px", + cursor: d.count <= 1 ? "default" : "pointer", + typography: "body2", + fontWeight: 700, + letterSpacing: "-0.01em", + color: + d.count <= 1 + ? "text.disabled" + : active + ? "primary.contrastText" + : "text.secondary", + whiteSpace: "nowrap", + transition: "color 160ms ease", + "&:focus-visible": { outline: "2px solid", outlineColor: "primary.main" }, }} - style={{ width: `${maxBar ? Math.max((g.metrics.sum / maxBar) * 100, 2) : 2}%` }} - /> - - {formatCurrency(g.metrics.sum, slice.currency)} - - - {g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"} - + > + {d.label} + + ); + })} + + + {visibleBars.length === 0 ? ( + + + No data for this slice. Try another granularity, period or payer. + + + ) : ( + + + + {visibleBars.map((b) => ( + + + {b.key} + + + + {formatCurrency(b.sum, slice.currency)} + + + {b.count} txn{b.count === 1 ? "" : "s"} + + + ))} + + {barScroll.overflow && ( + + scrollBarsByPage(-1)} + > + + + scrollBarsByPage(1)} + > + + + + )} - ))} - - )} + + )} + {slice.txns.length === 0 ? null : fields ? ( diff --git a/src/Reports/types.ts b/src/Reports/types.ts index 8056f3f..7d07808 100644 --- a/src/Reports/types.ts +++ b/src/Reports/types.ts @@ -231,4 +231,48 @@ export function buildReportFieldConfigs(resources: ResourceConfig[]): ReportFiel const generatedAt = find("generated_at"); if (!name || !generatedAt) return null; return { name, generatedAt }; +} + +export type ReportDim = "period" | "payee" | "tag"; + +export interface DimensionBar { + key: string; + sum: number; + count: number; +} + +/** + * Bar-chart rows for a given dimension. "period" reuses the period groups; + * "payee"/"tag" fold each bucket's metrics into every value listed under + * group_key[dim] — safe because server buckets are disjoint slices. + */ +export function buildDimensionBars(buckets: any[], filter: SliceFilter, dim: ReportDim): DimensionBar[] { + if (dim === "period") { + return buildPeriodGroups(buckets, filter).map((g) => ({ + key: g.key, + sum: g.metrics.sum, + count: g.metrics.count, + })); + } + const acc = new Map(); + for (const bucket of buckets ?? []) { + if (!bucketMatches(bucket, filter)) continue; + const keys: string[] = bucket.group_key?.[dim] ?? []; + for (const period of bucket.series?.[filter.granularity] ?? []) { + if (filter.periods?.length && !filter.periods.includes(period.period_id)) continue; + const m = period.metrics ?? {}; + const sum = typeof m.sum === "number" ? m.sum : 0; + const count = typeof m.count === "number" ? m.count : 0; + for (const k of keys) { + const cur = acc.get(k); + if (cur) { + cur.sum += sum; + cur.count += count; + } else { + acc.set(k, { key: k, sum, count }); + } + } + } + } + return [...acc.values()].sort((a, b) => Math.abs(b.sum) - Math.abs(a.sum)); } \ No newline at end of file