feat(reports): dimension-switchable bar strip (period/payees/tags)

Segmented pill control with sliding indicator; pills disabled when the
report has <=1 distinct value for that dim. Bars show top 10 by |sum|
in a fixed-height view paged by chevrons (hidden scrollbar); widths are
magnitude-based so all-outflow slices render correctly.
This commit is contained in:
2026-08-21 18:36:03 +05:30
parent 3837c9239e
commit c04c8fa00f
2 changed files with 228 additions and 32 deletions

View File

@@ -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<string | null>(null);
const [flow, setFlow] = useState("outflows");
const [dim, setDim] = useState<ReportDim>("period");
const [selectedPeriods, setSelectedPeriods] = useState<string[]>([]);
const [selectedPayees, setSelectedPayees] = useState<string[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
@@ -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<ListPeriodGroup[]>(
() =>
periodGroups.map((g) => ({
@@ -167,6 +175,32 @@ export function ReportViewer({ id, version, fields, onClose, onRegenerated }: Re
[periodGroups],
);
const barsListRef = useRef<HTMLDivElement>(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 (
<Paper variant="outlined" sx={{ p: 2.5, borderRadius: 3 }}>
@@ -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
<StatCard label="Transactions" value={slice.count.toLocaleString("en-IN")} />
</Box>
{periodGroups.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>
</Paper>
) : (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 2, mb: 2.5 }}>
{periodGroups.map((g) => (
<Box key={g.key} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
{g.key}
</Typography>
<Box sx={{ mb: 2.5 }}>
<Box
sx={{
position: "relative",
display: "flex",
alignItems: "stretch",
width: "fit-content",
maxWidth: "100%",
border: "1px solid",
borderColor: "divider",
borderRadius: "999px",
p: 0.5,
backgroundColor: "background.default",
}}
>
<Box
aria-hidden
sx={{
position: "absolute",
top: 4,
bottom: 4,
left: 4,
width: `calc((100% - 8px) / ${dimOptions.length})`,
borderRadius: "999px",
backgroundColor: "primary.main",
transform: `translateX(${activeDimIndex * 100}%)`,
transition: "transform 250ms cubic-bezier(0.4, 0, 0.2, 1)",
}}
/>
{dimOptions.map((d) => {
const active = d.id === dim;
return (
<Box
key={d.id}
component="button"
type="button"
disabled={d.count <= 1}
onClick={() => 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}%` }}
/>
<Typography variant="body2" fontWeight={600}>
{formatCurrency(g.metrics.sum, slice.currency)}
</Typography>
<Typography variant="caption" color="text.disabled">
{g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"}
</Typography>
>
{d.label}
</Box>
);
})}
</Box>
{visibleBars.length === 0 ? (
<Paper variant="outlined" sx={{ borderRadius: 2, p: 3, mt: 1.5 }}>
<Typography variant="body2" color="text.secondary">
No data for this slice. Try another granularity, period or payer.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ borderRadius: 2, mt: 1.5 }}>
<Box sx={{ display: "flex", alignItems: "stretch" }}>
<Box
ref={barsListRef}
onScroll={updateBarScroll}
sx={{
flex: 1,
minWidth: 0,
maxHeight: 220,
overflowY: "auto",
py: 1,
px: 2,
scrollbarWidth: "none",
"&::-webkit-scrollbar": { display: "none" },
}}
>
{visibleBars.map((b) => (
<Box key={b.key} sx={{ display: "flex", alignItems: "center", gap: 1.5, py: 0.75 }}>
<Typography variant="caption" sx={{ width: 110, flexShrink: 0, textAlign: "right" }}>
{b.key}
</Typography>
<Box
sx={{
height: 20,
borderRadius: 1,
bgcolor: flow === "outflows" ? "error.main" : "success.main",
opacity: 0.85,
minWidth: 4,
flexGrow: 0,
}}
style={{
width: `${maxBar ? Math.max((Math.abs(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>
))}
</Box>
{barScroll.overflow && (
<Box sx={{ display: "flex", flexDirection: "column", justifyContent: "center", gap: 0.5, pr: 1.5 }}>
<IconButton
size="small"
aria-label="Scroll bars up"
disabled={!barScroll.canUp}
onClick={() => scrollBarsByPage(-1)}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
aria-label="Scroll bars down"
disabled={!barScroll.canDown}
onClick={() => scrollBarsByPage(1)}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
))}
</Paper>
)}
</Paper>
)}
</Box>
{slice.txns.length === 0 ? null : fields ? (
<TransactionList fields={fields} groups={listGroups} showMetrics />

View File

@@ -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<string, DimensionBar>();
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));
}