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 { 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"; 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; version: number; fields: TxnFieldConfigs | null; onClose: () => void; 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 { get } = useResource("reports"); const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [reload, setReload] = useState(0); const [granularity, setGranularity] = useState(null); const [flow, setFlow] = useState("outflows"); const [selectedPeriods, setSelectedPeriods] = useState([]); const [selectedPayees, setSelectedPayees] = useState([]); const [selectedTags, setSelectedTags] = useState([]); const prevGranularity = useRef(null); const params = useMemo(() => { const p: Record = { 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); const previous = prevGranularity.current; get(id, params) .then((res) => { if (!mounted) return; setReport(res); 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(apiErrorMessage(e)); }) .finally(() => { if (mounted) setLoading(false); }); return () => { mounted = false; }; }, [id, version, reload, params, get, granularity]); const granularityOptions = useMemo(() => snapshotGranularities(report), [report]); 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 filter = useMemo( () => ({ granularity: granularity ?? granularityOptions[0] ?? "", periods: selectedPeriods, payees: selectedPayees, tags: selectedTags, }), [granularity, granularityOptions, selectedPeriods, selectedPayees, selectedTags], ); const periodGroups = useMemo(() => buildPeriodGroups(report?.buckets ?? [], filter), [report, filter]); const slice = useMemo(() => sliceSummary(periodGroups), [periodGroups]); const listGroups = useMemo( () => periodGroups.map((g) => ({ key: g.key, label: g.key, items: g.txns, spent: g.metrics.outflows, income: g.metrics.inflows, currency: g.currency, metrics: { sum: g.metrics.sum, count: g.metrics.count, avg: g.metrics.avg, min: g.metrics.min, max: g.metrics.max, cadence: g.metrics.cadence, frequency: g.metrics.frequency, }, })), [periodGroups], ); if (loading && !report) { return ( ); } if (error) { return ( {error} ); } if (!report) return null; const activeGranularity = granularity ?? report.granularities?.[0] ?? ""; const maxBar = periodGroups.reduce((m, g) => Math.max(m, g.metrics.sum), 0); const range = report.query?.start_date || report.query?.end_date ? `range ${report.query.start_date || "…"} → ${report.query.end_date || "…"}` : null; return ( {report.name} flow {report.flow} · generated {report.generated_at ?? report.created_at} {range && ( {range} )} {report.payees?.length ?? 0} payees · {report.tags?.length ?? 0} tags onRegenerated(report)}> {granularityOptions.map((g: string) => ( setGranularity(g)} /> ))} Flow {slice.txns.length} transactions · {slice.count} rows {periodGroups.length === 0 ? ( No data for this slice. Try another granularity, period or payer. ) : ( {periodGroups.map((g) => ( {g.key} {formatCurrency(g.metrics.sum, slice.currency)} {g.metrics.count} txn{g.metrics.count === 1 ? "" : "s"} ))} )} {slice.txns.length === 0 ? null : fields ? ( ) : null} ); }