commit 17dfee5ab0da9d8afcc61b97ec67257f1a039435
parent e55a5551fee3e4853c381525d70dbe458716ef56
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date: Mon, 24 Nov 2025 17:13:12 +0900
Statistics: Add tables, fixes #10645
Diffstat:
3 files changed, 126 insertions(+), 17 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/OrdersChart.tsx b/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/OrdersChart.tsx
@@ -51,7 +51,10 @@ export function OrdersChart({
return (
<Fragment>
<div>
- <div class="column ">
+ <section class="section is-size-4 hero is-hero-bar">
+ {i18n.str`Order statistics`}
+ </section>
+ <div class="column ">
<div class="buttons is-right">
<div class="field has-addons">
{filterFromDate && (
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/RevenueChart.tsx b/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/RevenueChart.tsx
@@ -19,7 +19,7 @@
* @author Martin Schanzenbach
*/
-import { AbsoluteTime } from "@gnu-taler/taler-util";
+import { AbsoluteTime, Amount, AmountJson, Amounts } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
@@ -29,11 +29,13 @@ import { FormProvider } from "../../../../components/form/FormProvider.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputGroup } from "../../../../components/form/InputGroup.js";
+import { ChartDataset, Point } from "chart.js";
+import { format } from "date-fns";
export interface RevenueChartProps {
currencyString: string,
- chartData: any;
- chartLabels: any;
+ chartData?: ChartDataset[];
+ chartLabels: number[];
chartOptions: any;
revenueRanges: string[];
onShowRange: (rangeCount: number, range?: string) => void;
@@ -50,6 +52,7 @@ export function RevenueChart({
activeRange: activeRange,
}: RevenueChartProps): VNode {
const { i18n } = useTranslationContext();
+ const [showTable, setShowTable] = useState<boolean>(false);
const translateMap = new Map<string, [string, string]>([
["hour", [i18n.str`hour`, i18n.str`hours`]],
["day", [i18n.str`day`, i18n.str`days`]],
@@ -63,15 +66,31 @@ export function RevenueChart({
return "";
}
return (range.rangeCount < 2) ? translateMap.get(range.range!)![0] : translateMap.get(range.range!)![1];
+ };
+ const formatMap = new Map<string, string>([
+ ["hour", "ha"],
+ ["day", 'MMM d'],
+ ["week", 'PP'],
+ ["month", 'MMM yyyy'],
+ ["quarter", 'qqq - yyyy'],
+ ["year", 'yyyy'],
+ ]);
+ function getRevenueForLabelAndDataset(label: number, ds: ChartDataset) : string {
+ for (let d of (ds.data as (Point & AmountJson)[])) {
+ if (d.x === label) {
+ console.log(Amounts.toPretty(d));
+ return Amounts.toPretty(d);
+ }
+ }
+ return "-";
}
-
const form = activeRange;
return (
<Fragment>
+ <section class="section is-size-4 hero is-hero-bar">
+ {i18n.str`Revenue statistics over the past ${activeRange.rangeCount} ${translateRange(activeRange)} for currency '${currencyString}'`}
+ </section>
<div class="columns">
- <div class="column is-two-thirds">
- {i18n.str`Revenue statistics over the past ${activeRange.rangeCount} ${translateRange(activeRange)} for currency '${currencyString}'`}
- </div>
<div class="column ">
<FormProvider
object={form}
@@ -101,11 +120,93 @@ export function RevenueChart({
</FormProvider>
</div>
</div>
-
+ <div class="tabs" style={{ overflow: "inherit" }}>
+ <ul>
+ <li class={(!showTable) ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Show chart`}
+ >
+ <a onClick={() => {setShowTable(false) }}>
+ <i18n.Translate>Chart</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ <li class={(showTable) ? "is-active" : ""}>
+ <div
+ class="has-tooltip-right"
+ data-tooltip={i18n.str`Show table`}
+ >
+ <a onClick={() => {setShowTable(true) }}>
+ <i18n.Translate>Table</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
{(chartData && chartLabels.length > 0) ? (
- <Bar data={{labels: chartLabels, datasets: chartData}} options={chartOptions}/>
+ (!showTable) ? (
+ <Bar data={{labels: chartLabels, datasets: chartData as any}} options={chartOptions}/>
+ ) : (
+ <div class="card has-table">
+ <header class="card-header">
+ <p class="card-header-title">
+ <span class="icon">
+ <i class="mdi mdi-shopping" />
+ </span>
+ <i18n.Translate>Table</i18n.Translate>
+ </p>
+ </header>
+ <div class="card-content">
+ <div class="b-table has-pagination">
+ <div class="table-wrapper has-mobile-cards">
+ <div class="table-container">
+ <table class="table is-fullwidth is-striped is-hoverable is-fullwidth">
+ <thead>
+ <tr>
+ <th>
+ <i18n.Translate>Start time</i18n.Translate>
+ </th>
+ {chartData!.map((d) => {
+ return (
+ <Fragment key={d.label}>
+ <th>
+ {d.label}
+ </th>
+ </Fragment>
+ )})}
+ </tr>
+ </thead>
+ <tbody>
+ {chartLabels.map((l) => {
+ return (
+ <Fragment key={l}>
+ <tr key="info">
+ <td>
+ {format(new Date(l), formatMap.get(activeRange.range!)!)}
+ </td>
+ {chartData?.map((d) => {
+ return (
+ <Fragment key={d.label}>
+ <td>
+ {getRevenueForLabelAndDataset(l, d)}
+ </td>
+ </Fragment>
+ );})}
+ </tr>
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
) : (
- <i>{i18n.str`No revenue statistics yet.`}</i>
+ <i>{i18n.str`No revenue statistics yet.`}</i>
)}
</Fragment>
);
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx
@@ -27,6 +27,8 @@ import {
TalerError,
assertUnreachable,
StatisticsCounter,
+ Amount,
+ AmountJson,
} from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
@@ -693,20 +695,25 @@ export default function Statistics({
if (!datasetForCurrency.has(a.currency)) {
datasetForCurrency.set(a.currency, {
label: stat.buckets_description,
- data: Array<Point>(),
+ data: Array<Point & AmountJson>(),
backgroundColor: datasetColor,
hoverOffset: 4,
})
}
const amount = a.value + (a.fraction / Math.pow(10, 8));
- datasetForCurrency.get(a.currency)!.data.push({x: b.start_time.t_s * 1000, y: amount});
+ const timeRangeStart = b.start_time.t_s * 1000;
+ datasetForCurrency.get(a.currency)!.data.push({x: timeRangeStart, y: amount, ...a});
if (currRangeFilter.rangeCount < datasetForCurrency.get(a.currency)!.data.length) {
datasetForCurrency.get(a.currency)?.data.shift(); // Remove oldest entry
+ revenueChartLabels.get(a.currency)?.shift(); // Remove oldest entry
}
if (!revenueChartLabels.has(a.currency)) {
revenueChartLabels.set(a.currency, []);
}
- revenueChartLabels.get(a.currency)?.push(b.start_time.t_s * 1000);
+ const labels = revenueChartLabels.get(a.currency);
+ if (labels?.indexOf(timeRangeStart) === -1) {
+ labels?.push(timeRangeStart);
+ }
}
}
datasetForCurrency.forEach((value: ChartDataset, key: string) => {
@@ -769,7 +776,6 @@ export default function Statistics({
<section class="section is-main-section">
<NotificationCard notification={notif} />
- <h2>{i18n.str`Revenue statistics`}</h2>
<div>
{
(revenueCurrencies.length > 0) ? (
@@ -779,7 +785,7 @@ export default function Statistics({
tmpOpt.scales.x.time.unit = rangeFilter.get(c)?.range ?? "quarter";
return <RevenueChart
currencyString={c}
- chartLabels={revenueChartLabels.get(c)}
+ chartLabels={revenueChartLabels.get(c) ?? []}
chartData={revenueChartDatasets.get(c)}
chartOptions={tmpOpt as ChartOptions}
activeRange={rangeFilter.get(c)!}
@@ -792,7 +798,6 @@ export default function Statistics({
}
</div>
<hr/>
- <h2>{i18n.str`Order statistics`}</h2>
<OrdersChart
chartLabels={orderStatsChartLabels}
chartData={orderStatsChartDatasets}