commit 4234ba5cbd9ab0ddc7cc5690c6e74a73465a9d91
parent 7196778e16d53dcd3d6eab815d505ad814b4f8da
Author: Sebastian <sebasjm@taler-systems.com>
Date: Wed, 26 Nov 2025 16:43:29 -0300
fix #10646
- use duration API to calculate last month
- get currency from config
- get available currencoies from config
- dont use var, and use const ver let when possible
- dont use setState on render loop: setStartOrdersFromDate
- use i18n for labels
- replace react-charjs-2 for direct charjs use
- dont use any: chartOptions
- startOrdersFromDate can be "never" on scales.x.min
- dont use hooks inside a loop
- the hook to load info can query in paralel
- remove useState notification
- what data inside the component that is going to use it
Diffstat:
9 files changed, 866 insertions(+), 637 deletions(-)
diff --git a/packages/merchant-backoffice-ui/package.json b/packages/merchant-backoffice-ui/package.json
@@ -26,7 +26,6 @@
"preact": "10.11.3",
"preact-router": "3.2.1",
"qrcode-generator": "1.4.4",
- "react-chartjs-2": "^5.3.1",
"swr": "2.2.2"
},
"devDependencies": {
diff --git a/packages/merchant-backoffice-ui/src/components/ChartJS.tsx b/packages/merchant-backoffice-ui/src/components/ChartJS.tsx
@@ -0,0 +1,119 @@
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ChartData, ChartOptions } from "chart.js";
+import { h, VNode } from "preact";
+import { useEffect, useRef } from "preact/hooks";
+import {
+ BarController,
+ BarElement,
+ CategoryScale,
+ Chart,
+ Filler,
+ Legend,
+ LinearScale,
+ LineController,
+ LineElement,
+ PointElement,
+ TimeScale,
+ Title,
+ Tooltip,
+} from "chart.js";
+
+Chart.register(
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ LineElement,
+ PointElement,
+ BarElement,
+ BarController,
+ LineController,
+ Title,
+ Filler,
+ Tooltip,
+ Legend,
+);
+
+/**
+ *
+ * @param param0
+ * @returns
+ */
+export function BarCanvas({
+ data,
+ options,
+}: {
+ data: ChartData<"bar", unknown, unknown>;
+ options: ChartOptions<"bar">;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const chartRef = useRef<Chart<"bar", unknown, unknown> | null>(null);
+
+ useEffect(() => {
+ if (!canvasRef.current) return;
+ chartRef.current = new Chart(canvasRef.current, {
+ type: "bar" as const,
+ data,
+ options,
+ });
+
+ return () => {
+ if (chartRef.current) {
+ chartRef.current.destroy();
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!chartRef.current) {
+ return;
+ }
+ chartRef.current.data = data;
+ chartRef.current.options = options;
+ chartRef.current.update();
+ }, [data, options]);
+
+ return (
+ <canvas ref={canvasRef} role="img">
+ <i18n.Translate>
+ Your browser does not support the canvas element.
+ </i18n.Translate>
+ </canvas>
+ );
+}
+
+export function LineCanvas({
+ data,
+ options,
+}: {
+ data: ChartData<"line", unknown, unknown>;
+ options: ChartOptions<"line">;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const chartRef = useRef<Chart<"line", unknown, unknown> | null>(null);
+
+ useEffect(() => {
+ if (!canvasRef.current) return;
+ chartRef.current = new Chart(canvasRef.current, {
+ type: "line" as const,
+ data,
+ options,
+ plugins: [],
+ });
+
+ return () => {
+ if (chartRef.current) {
+ chartRef.current.destroy();
+ }
+ };
+ }, []);
+
+ return (
+ <canvas ref={canvasRef} role="img">
+ <i18n.Translate>
+ Your browser does not support the canvas element.
+ </i18n.Translate>
+ </canvas>
+ );
+}
diff --git a/packages/merchant-backoffice-ui/src/hooks/statistics.ts b/packages/merchant-backoffice-ui/src/hooks/statistics.ts
@@ -16,72 +16,139 @@
import { useState } from "preact/hooks";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
-import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util";
-import _useSWR, { SWRHook, mutate } from "swr";
+import {
+ AccessToken,
+ OperationOk,
+ opFixedSuccess,
+ StatisticsAmount,
+ StatisticsCounter,
+ TalerHttpError,
+ TalerMerchantManagementErrorsByMethod
+} from "@gnu-taler/taler-util";
+import _useSWR, { mutate, SWRHook } from "swr";
import { useSessionContext } from "../context/session.js";
const useSWR = _useSWR as unknown as SWRHook;
-
-export interface InstanceTemplateFilter {
-}
+export type MerchantStatsSlug =
+ | MerchantOrderStatsSlug
+ | MerchantRevenueStatsSlug;
+
+export const MERCHANT_ORDER_STATS_SLUG = [
+ "orders-created",
+ "orders-claimed",
+ "orders-paid",
+ "orders-settled",
+] as const;
+
+export const MERCHANT_REVENUE_STATS_SLUG = [
+ "payments-received-after-deposit-fee",
+ "total-wire-fees-paid",
+ "refunds-granted",
+ "total-deposit-fees-paid",
+] as const;
+
+export type MerchantOrderStatsSlug = (typeof MERCHANT_ORDER_STATS_SLUG)[number];
+export type MerchantRevenueStatsSlug =
+ (typeof MERCHANT_REVENUE_STATS_SLUG)[number];
+
+export interface InstanceTemplateFilter {}
export function revalidateInstanceStatisticsCounter() {
return mutate(
- (key) => Array.isArray(key) && key[key.length - 1] === "getStatisticsCounter",
+ (key) =>
+ Array.isArray(key) && key[key.length - 1] === "getStatisticsCounter",
undefined,
{ revalidate: true },
);
}
-export function useInstanceStatisticsCounter(slug: string) {
+export function useInstanceStatisticsCounter() {
const { state, lib } = useSessionContext();
const [offset] = useState<string | undefined>();
- async function fetcher([token, slug, intervalOrBucket]: [AccessToken, string, string]) {
- return await lib.instance.getStatisticsCounter(token, slug, {
- by: "ANY",
+ async function fetcher([token]: [
+ AccessToken,
+ MerchantOrderStatsSlug,
+ string,
+ ]) {
+ const resp = await Promise.all(
+ MERCHANT_ORDER_STATS_SLUG.map((r) => {
+ return lib.instance.getStatisticsCounter(token, r);
+ }),
+ );
+ for (const r of resp) {
+ // if one fail, all fail
+ if (r.type === "fail") {
+ return r;
+ }
+ }
+ const result = new Map<MerchantOrderStatsSlug, StatisticsCounter>();
+
+ MERCHANT_ORDER_STATS_SLUG.forEach((r, idx) => {
+ if (resp[idx].type === "ok") {
+ result.set(r, resp[idx].body);
+ }
});
+ return opFixedSuccess(result);
}
const { data, error } = useSWR<
- TalerMerchantManagementResultByMethod<"getStatisticsCounter">,
+ | TalerMerchantManagementErrorsByMethod<"getStatisticsCounter">
+ | OperationOk<Map<MerchantOrderStatsSlug, StatisticsCounter>>,
TalerHttpError
- >([state.token, slug, "ANY"], fetcher);
+ >([state.token, "ANY"], fetcher);
if (error) return error;
if (data === undefined) return undefined;
if (data.type !== "ok") return data;
return data;
-
}
export function revalidateInstanceStatisticsAmount() {
return mutate(
- (key) => Array.isArray(key) && key[key.length - 1] === "getStatisticsAmount",
+ (key) =>
+ Array.isArray(key) && key[key.length - 1] === "getStatisticsAmount",
undefined,
{ revalidate: true },
);
}
-export function useInstanceStatisticsAmount(slug: string) {
+export function useInstanceStatisticsAmount() {
const { state, lib } = useSessionContext();
const [offset] = useState<string | undefined>();
- async function fetcher([token, slug, intervalOrBucket]: [AccessToken, string, string]) {
- return await lib.instance.getStatisticsAmount(token, slug, {
- by: "ANY",
+ async function fetcher([token]: [AccessToken]) {
+ const resp = await Promise.all(
+ MERCHANT_REVENUE_STATS_SLUG.map((r) => {
+ return lib.instance.getStatisticsAmount(token, r);
+ }),
+ );
+
+ for (const r of resp) {
+ // if one fail, all fail
+ if (r.type === "fail") {
+ return r;
+ }
+ }
+ const result = new Map<MerchantRevenueStatsSlug, StatisticsAmount>();
+
+ MERCHANT_REVENUE_STATS_SLUG.forEach((r, idx) => {
+ if (resp[idx].type === "ok") {
+ result.set(r, resp[idx].body);
+ }
});
+ return opFixedSuccess(result);
}
const { data, error } = useSWR<
- TalerMerchantManagementResultByMethod<"getStatisticsAmount">,
+ | TalerMerchantManagementErrorsByMethod<"getStatisticsAmount">
+ | OperationOk<Map<MerchantRevenueStatsSlug, StatisticsAmount>>,
TalerHttpError
- >([state.token, slug, "ANY"], fetcher);
+ >([state.token, undefined], fetcher);
if (error) return error;
if (data === undefined) return undefined;
if (data.type !== "ok") return data;
return data;
-
}
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
@@ -19,79 +19,137 @@
* @author Martin Schanzenbach
*/
-import { AbsoluteTime } from "@gnu-taler/taler-util";
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ InternationalizationAPI,
+ StatisticsCounter,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ChartDataset, ChartOptions, Point } from "chart.js";
import { format } from "date-fns";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { DatePicker } from "../../../../components/picker/DatePicker.js";
-import { dateFormatForSettings, usePreference } from "../../../../hooks/preference.js";
-import { Line } from "react-chartjs-2";
-import { ChartDataset, Point } from "chart.js";
+import {
+ dateFormatForSettings,
+ usePreference,
+} from "../../../../hooks/preference.js";
+import {
+ MerchantOrderStatsSlug,
+ useInstanceStatisticsCounter,
+} from "../../../../hooks/statistics.js";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import { LineCanvas } from "../../../../components/ChartJS.js";
const TALER_SCREEN_ID = 58;
export interface OrdersChartProps {
- chartData: ChartDataset[];
- chartLabels: number[];
- chartOptions: any;
+ colors: Map<MerchantOrderStatsSlug, string>;
filterFromDate?: AbsoluteTime;
onSelectDate: (date?: AbsoluteTime) => void;
}
+function getCountForLabelAndDataset(
+ label: number,
+ ds: ChartDataset<"line", Point[]>,
+): number {
+ for (let d of ds.data) {
+ if (d.x === label) {
+ return d.y;
+ }
+ }
+ return 0;
+}
+
export function OrdersChart({
- chartData,
- chartLabels,
- chartOptions,
+ colors,
filterFromDate,
onSelectDate,
}: OrdersChartProps): VNode {
const { i18n } = useTranslationContext();
const [settings] = usePreference();
- const dateTooltip = i18n.str`Select date from which to show statistics`;
const [pickDate, setPickDate] = useState(false);
const [showTable, setShowTable] = useState<boolean>(false);
- function getCountForLabelAndDataset(label: number, ds: ChartDataset) : number {
- for (let d of (ds.data as Array<Point>)) {
- if (d.x === label) {
- return d.y;
- }
+ const counters = useInstanceStatisticsCounter();
+ if (!counters) {
+ return <Loading />;
+ }
+ if (counters instanceof TalerError) {
+ return <ErrorLoadingMerchant error={counters} />;
+ }
+ if (counters.type === "fail") {
+ switch (counters.case) {
+ case HttpStatusCode.Unauthorized:
+ return <LoginPage />;
+ case HttpStatusCode.NotFound:
+ return <NotFoundPageOrAdminCreate />;
+ case HttpStatusCode.BadGateway:
+ return (
+ <NotificationCard
+ notification={{
+ message: i18n.str`Bad gateway`,
+ type: "ERROR",
+ }}
+ />
+ );
+
+ case HttpStatusCode.ServiceUnavailable:
+ return (
+ <NotificationCard
+ notification={{
+ message: i18n.str`Service unavailable`,
+ type: "ERROR",
+ }}
+ />
+ );
}
- return 0;
}
+ const chart = filterOrderCharData(colors, counters.body, filterFromDate);
+
+ const dateTooltip = i18n.str`Select date from which to show statistics`;
return (
<Fragment>
<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>Orders 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>Orders table</i18n.Translate>
- </a>
- </div>
- </li>
- </ul>
- </div>
- <div class="columns">
- <div class="column">
+ <ul>
+ <li class={!showTable ? "is-active" : ""}>
+ <div class="has-tooltip-right" data-tooltip={i18n.str`Show chart`}>
+ <a
+ onClick={() => {
+ setShowTable(false);
+ }}
+ >
+ <i18n.Translate>Orders 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>Orders table</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
+ <div class="columns">
+ <div class="column">
<div class="buttons is-right">
<div class="field has-addons">
{filterFromDate && (
<div class="control">
- <a class="button is-fullwidth" onClick={() => onSelectDate(undefined)}>
+ <a
+ class="button is-fullwidth"
+ onClick={() => onSelectDate(undefined)}
+ >
<span
class="icon"
data-tooltip={i18n.str`Clear date filter`}
@@ -107,8 +165,17 @@ export function OrdersChart({
class="input"
type="text"
readonly
- value={!filterFromDate || filterFromDate.t_ms === "never" ? "" : format(filterFromDate.t_ms, dateFormatForSettings(settings))}
- placeholder={i18n.str`Start from (${dateFormatForSettings(settings)})`}
+ value={
+ !filterFromDate || filterFromDate.t_ms === "never"
+ ? ""
+ : format(
+ filterFromDate.t_ms,
+ dateFormatForSettings(settings),
+ )
+ }
+ placeholder={i18n.str`Start from (${dateFormatForSettings(
+ settings,
+ )})`}
onClick={() => {
setPickDate(true);
}}
@@ -132,16 +199,16 @@ export function OrdersChart({
</div>
</div>
</div>
- </div>
+ </div>
<DatePicker
opened={pickDate}
closeFunction={() => setPickDate(false)}
dateReceiver={(d) => {
- onSelectDate(AbsoluteTime.fromMilliseconds(d.getTime()))
+ onSelectDate(AbsoluteTime.fromMilliseconds(d.getTime()));
}}
/>
- <div class="card has-table">
+ <div class="card has-table">
<header class="card-header">
<p class="card-header-title">
<span class="icon">
@@ -151,62 +218,163 @@ export function OrdersChart({
</p>
</header>
<div class="card-content">
- {(chartData && chartLabels.length > 0) ? (
- (!showTable) ? (
- <Line data={{labels: chartLabels, datasets: chartData as any}} options={chartOptions}/>
+ {chart.datasets.length > 0 && chart.labels.length > 0 ? (
+ !showTable ? (
+ <LineCanvas
+ data={chart}
+ options={orderStatsChartOptions(i18n, chart.smallestOrderDate)}
+ />
) : (
<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>Since</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>
- {new Date(l).toLocaleString()}
- </td>
- {chartData?.map((d) => {
- return (
- <Fragment key={d.label}>
- <td>
- {getCountForLabelAndDataset(l, d)}
- </td>
- </Fragment>
- );})}
- </tr>
- </Fragment>
- );
- })}
- </tbody>
- </table>
- </div>
- </div>
- </div>
-
+ <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>Since</i18n.Translate>
+ </th>
+ {chart.datasets.map((d) => {
+ return (
+ <Fragment key={d.label}>
+ <th>{d.label}</th>
+ </Fragment>
+ );
+ })}
+ </tr>
+ </thead>
+ <tbody>
+ {chart.labels.map((l) => {
+ return (
+ <Fragment key={l}>
+ <tr key="info">
+ <td>{new Date(l).toLocaleString()}</td>
+ {chart.datasets.map((d) => {
+ return (
+ <Fragment key={d.label}>
+ <td>
+ {getCountForLabelAndDataset(l, d)}
+ </td>
+ </Fragment>
+ );
+ })}
+ </tr>
+ </Fragment>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
)
) : (
- <i>{i18n.str`No order statistics yet.`}</i>
- )
- }
+ <i>{i18n.str`No order statistics yet.`}</i>
+ )}
</div>
</div>
</Fragment>
);
}
+
+function orderStatsChartOptions(
+ i18n: InternationalizationAPI,
+ startOrdersFromDate: number | undefined,
+): ChartOptions<"line"> {
+ return {
+ plugins: {
+ title: {
+ display: true,
+ text: i18n.str`Orders`,
+ },
+ filler: {
+ propagate: true,
+ },
+ },
+ responsive: true,
+ scales: {
+ x: {
+ min: startOrdersFromDate,
+ type: "time",
+ time: { unit: "day", displayFormats: { day: "Pp" } },
+ ticks: { source: "data" },
+ stacked: true,
+ title: { display: false },
+ },
+ y: {
+ stacked: true,
+ title: {
+ display: true,
+ text: i18n.str`# of orders since`,
+ align: "end",
+ },
+ },
+ },
+ };
+}
+function filterOrderCharData(
+ colors: Map<MerchantOrderStatsSlug, string>,
+ ordersStats: Map<MerchantOrderStatsSlug, StatisticsCounter>,
+ startOrdersFromDate: AbsoluteTime | undefined,
+): {
+ datasets: ChartDataset<"line", Point[]>[];
+ labels: number[];
+ smallestOrderDate: number | undefined;
+} {
+ let smallestOrderDate: number | undefined;
+ const labels: number[] = [];
+ const datasets: ChartDataset<"line", Point[]>[] = [];
+ ordersStats.forEach((stat, slug) => {
+ const datasetColor = colors.get(slug) ?? "#eeeeee";
+ const info: ChartDataset<"line", Point[]> = {
+ label: stat.intervals_description,
+ data: [],
+ backgroundColor: datasetColor,
+ // hoverOffset: 4,
+ fill: {
+ target: "-1",
+ below: datasetColor,
+ },
+ };
+ let accum = 0;
+ for (let j = stat.intervals.length - 1; j >= 0; j--) {
+ const interval = stat.intervals[j];
+ if (interval.start_time.t_s == "never") {
+ continue;
+ }
+ accum += interval.cumulative_counter;
+ info.data.push({ x: interval.start_time.t_s * 1000, y: accum });
+ // Do not add label if outside of range
+ const intervalStart = interval.start_time.t_s * 1000;
+ if (
+ startOrdersFromDate &&
+ "never" !== startOrdersFromDate.t_ms &&
+ intervalStart < startOrdersFromDate.t_ms
+ ) {
+ continue;
+ }
+ if (-1 === labels.indexOf(intervalStart)) {
+ labels.push(intervalStart);
+ }
+ if (!smallestOrderDate) {
+ smallestOrderDate = intervalStart;
+ }
+ if (smallestOrderDate > intervalStart) {
+ smallestOrderDate = intervalStart;
+ }
+ }
+ if (info.data.length) {
+ datasets.push(info);
+ }
+ });
+
+ // Hack to make first area to origin work.
+ if (
+ datasets.length &&
+ typeof datasets[0].fill === "object" &&
+ "target" in datasets[0].fill
+ ) {
+ datasets[0].fill = "origin";
+ }
+ return { datasets, labels, smallestOrderDate };
+}
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,152 +19,194 @@
* @author Martin Schanzenbach
*/
-import { AmountJson, Amounts, StatisticBucketRange } from "@gnu-taler/taler-util";
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
-import { ChartDataset, Point } from "chart.js";
+import {
+ AmountJson,
+ Amounts,
+ HttpStatusCode,
+ InternationalizationAPI,
+ StatisticBucketRange,
+ StatisticsAmount,
+ TalerError,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import type { ChartDataset, ChartOptions, Point } from "chart.js";
import { format } from "date-fns";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
-import { Bar } from "react-chartjs-2";
+import { BarCanvas } from "../../../../components/ChartJS.js";
+import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
import { FormProvider } from "../../../../components/form/FormProvider.js";
import { InputGroup } from "../../../../components/form/InputGroup.js";
import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
+import { NotificationCard } from "../../../../components/menu/index.js";
+import {
+ MerchantRevenueStatsSlug,
+ useInstanceStatisticsAmount,
+} from "../../../../hooks/statistics.js";
+import { LoginPage } from "../../../login/index.js";
+import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js";
+import { RelevantTimeUnit, RevenueChartFilter } from "./index.js";
const TALER_SCREEN_ID = 59;
-export interface RevenueChartFilter {
- rangeCount: number,
- range: string,
- currency: string,
-}
-
export interface RevenueChartProps {
- chartData?: ChartDataset[];
- chartLabels: number[];
- chartOptions: any;
+ colors: Map<MerchantRevenueStatsSlug, string>;
availableCurrencies: string[];
- activeFilter: {range: string, currency: string, rangeCount: number};
+ activeFilter: {
+ range: RelevantTimeUnit;
+ currency: string;
+ rangeCount: number;
+ };
onUpdateFilter: (filter: RevenueChartFilter) => void;
}
export function RevenueChart({
- chartData,
- chartLabels,
- chartOptions,
+ colors,
onUpdateFilter,
availableCurrencies,
activeFilter: activeFilter,
}: RevenueChartProps): VNode {
const { i18n } = useTranslationContext();
const [showTable, setShowTable] = useState<boolean>(false);
- const translateMap = new Map<string, [string, string]>([
+ const translateMap = new Map<
+ RelevantTimeUnit,
+ [TranslatedString, TranslatedString]
+ >([
["hour", [i18n.str`hour`, i18n.str`hours`]],
["day", [i18n.str`day`, i18n.str`days`]],
["week", [i18n.str`week`, i18n.str`weeks`]],
["month", [i18n.str`month`, i18n.str`months`]],
["quarter", [i18n.str`quarter`, i18n.str`quarters`]],
["year", [i18n.str`years`, i18n.str`years`]],
- ])
- function translateRange(range: {rangeCount: number, range?: string}) : string {
+ ]);
+ function translateRange(range: {
+ rangeCount: number;
+ range?: RelevantTimeUnit;
+ }): TranslatedString {
if (!translateMap.has(range.range!)) {
- return "";
+ return "" as TranslatedString;
}
- return (range.rangeCount < 2) ? translateMap.get(range.range!)![0] : translateMap.get(range.range!)![1];
- };
- const revenueRanges = Object.values(StatisticBucketRange);
- const formatMap = new Map<string, string>([
- ["hour", 'yyyy MMMM d ha'],
- ["day", 'yyyy MMM d'],
- ["week", 'yyyy MMM w'],
- ["month", 'yyyy MMM'],
- ["quarter", 'yyyy qqq'],
- ["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 range.rangeCount < 2
+ ? translateMap.get(range.range!)![0]
+ : translateMap.get(range.range!)![1];
+ }
+ const revenues = useInstanceStatisticsAmount();
+ if (!revenues) {
+ return <Loading />;
+ }
+ if (revenues instanceof TalerError) {
+ return <ErrorLoadingMerchant error={revenues} />;
+ }
+ if (revenues.type === "fail") {
+ switch (revenues.case) {
+ case HttpStatusCode.Unauthorized:
+ return <LoginPage />;
+ case HttpStatusCode.NotFound:
+ return <NotFoundPageOrAdminCreate />;
+ case HttpStatusCode.BadGateway:
+ return (
+ <NotificationCard
+ notification={{
+ message: i18n.str`Bad gateway`,
+ type: "ERROR",
+ }}
+ />
+ );
+
+ case HttpStatusCode.ServiceUnavailable:
+ return (
+ <NotificationCard
+ notification={{
+ message: i18n.str`Service unavailable`,
+ type: "ERROR",
+ }}
+ />
+ );
}
- return "-";
}
+ const chart = filterRevenueCharData(colors, revenues.body, activeFilter);
+
const form = activeFilter;
return (
<Fragment>
<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>Revenue 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>Revenue table</i18n.Translate>
- </a>
- </div>
- </li>
- </ul>
- </div>
- <div class="columns">
+ <ul>
+ <li class={!showTable ? "is-active" : ""}>
+ <div class="has-tooltip-right" data-tooltip={i18n.str`Show chart`}>
+ <a
+ onClick={() => {
+ setShowTable(false);
+ }}
+ >
+ <i18n.Translate>Revenue 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>Revenue table</i18n.Translate>
+ </a>
+ </div>
+ </li>
+ </ul>
+ </div>
+ <div class="columns">
<div class="column">
<div class="buttons is-right">
- <FormProvider
- object={form}
- valueHandler={(updater) => onUpdateFilter(
- {
- rangeCount: updater(form).rangeCount ?? activeFilter.rangeCount,
- range: updater(form).range ?? activeFilter.range,
- currency: updater(form).currency ?? activeFilter.currency
- }
- )}
- >
- <InputGroup
- name="rangeFilter"
- label={i18n.str`Revenue statistics filter`}
+ <FormProvider
+ object={form}
+ valueHandler={(updater) =>
+ onUpdateFilter({
+ rangeCount:
+ updater(form).rangeCount ?? activeFilter.rangeCount,
+ range: updater(form).range ?? activeFilter.range,
+ currency: updater(form).currency ?? activeFilter.currency,
+ })
+ }
>
- <InputSelector
- name="range"
- label={i18n.str`Time range`}
- values={revenueRanges}
- fromStr={(d) => {
- const idx = revenueRanges.indexOf(d as StatisticBucketRange)
- if (idx === -1) return undefined;
- return d
- }}
- tooltip={i18n.str`Select time range to group dataset by`}
- />
- <InputNumber
- name="rangeCount"
- label={translateMap.get(activeFilter.range)![0]}
- tooltip={i18n.str`Select the number of ranges to include in data set`}
-
- />
- <InputSelector
- name="currency"
- label={i18n.str`Currency`}
- values={availableCurrencies}
- fromStr={(c) => {
- const idx = availableCurrencies.indexOf(c)
- if (idx === -1) return undefined;
- return c
- }}
- tooltip={i18n.str`Select the currency to show statistics for`}
- />
- </InputGroup>
- </FormProvider>
- </div>
+ <InputGroup
+ name="rangeFilter"
+ label={i18n.str`Revenue statistics filter`}
+ >
+ <InputSelector
+ name="range"
+ label={i18n.str`Time range`}
+ values={revenueRanges}
+ fromStr={(d) => {
+ const idx = revenueRanges.indexOf(
+ d as StatisticBucketRange,
+ );
+ if (idx === -1) return undefined;
+ return d;
+ }}
+ tooltip={i18n.str`Select time range to group dataset by`}
+ />
+ <InputNumber
+ name="rangeCount"
+ label={translateMap.get(activeFilter.range)![0]}
+ tooltip={i18n.str`Select the number of ranges to include in data set`}
+ />
+ <InputSelector
+ name="currency"
+ label={i18n.str`Currency`}
+ values={availableCurrencies}
+ fromStr={(c) => {
+ const idx = availableCurrencies.indexOf(c);
+ if (idx === -1) return undefined;
+ return c;
+ }}
+ tooltip={i18n.str`Select the currency to show statistics for`}
+ />
+ </InputGroup>
+ </FormProvider>
+ </div>
</div>
</div>
<div class="card has-table">
@@ -173,64 +215,179 @@ export function RevenueChart({
<span class="icon">
<i class="mdi mdi-shopping" />
</span>
- {i18n.str`Revenue statistics over the past ${activeFilter.rangeCount} ${translateRange(activeFilter)} for currency '${activeFilter.currency}'`}
+ {i18n.str`Revenue statistics over the past ${
+ activeFilter.rangeCount
+ } ${translateRange(activeFilter)} for currency '${
+ activeFilter.currency
+ }'`}
</p>
</header>
<div class="card-content">
- {(chartData && chartLabels.length > 0) ? (
- (!showTable) ? (
- <Bar data={{labels: chartLabels, datasets: chartData as any}} options={chartOptions}/>
- ) : (
- <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(activeFilter.range)!)}
- </td>
- {chartData?.map((d) => {
- return (
- <Fragment key={d.label}>
- <td>
- {getRevenueForLabelAndDataset(l, d)}
+ {chart.datasets.length > 0 && chart.labels.length > 0 ? (
+ !showTable ? (
+ <BarCanvas
+ data={chart}
+ options={revenueChartOptions(i18n, activeFilter)}
+ />
+ ) : (
+ <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>import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
+
+ <tr>
+ <th>
+ <i18n.Translate>Start time</i18n.Translate>
+ </th>
+ {chart.datasets.map((d) => {
+ return (
+ <Fragment key={d.label}>
+ <th>{d.label}</th>
+ </Fragment>
+ );
+ })}
+ </tr>
+ </thead>
+ <tbody>
+ {chart.labels.map((l) => {
+ return (
+ <Fragment key={l}>
+ <tr key="info">
+ <td>
+ {format(
+ new Date(l),
+ formatMap.get(activeFilter.range)!,
+ )}
</td>
- </Fragment>
- );})}
- </tr>
- </Fragment>
- );
- })}
- </tbody>
- </table>
+ {chart.datasets.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>
+ )}
</div>
</div>
- </Fragment>
+ </Fragment>
);
}
+
+const revenueRanges = Object.values(StatisticBucketRange);
+const formatMap = new Map<RelevantTimeUnit, string>([
+ ["hour", "yyyy MMMM d ha"],
+ ["day", "yyyy MMM d"],
+ ["week", "yyyy MMM w"],
+ ["month", "yyyy MMM"],
+ ["quarter", "yyyy qqq"],
+ ["year", "yyyy"],
+]);
+function getRevenueForLabelAndDataset(
+ label: number,
+ ds: ChartDataset<"bar", (Point & AmountJson)[]>,
+): string {
+ for (let d of ds.data) {
+ if (d.x === label) {
+ return Amounts.toPretty(d);
+ }
+ }
+ return "-";
+}
+
+function revenueChartOptions(
+ i18n: InternationalizationAPI,
+ filter: RevenueChartFilter,
+): ChartOptions<"bar"> {
+ // FIXME: End date picker?
+ // FIXME: Intelligent date range selection?
+ // revenueChartOptions.scales.y.title.text = revenueChartFilter.currency;
+ // revenueChartOptions.scales.x.time.unit = revenueChartFilter.range;
+ return {
+ plugins: {
+ title: {
+ display: true,
+ text: i18n.str`Revenue`,
+ },
+ },
+ responsive: true,
+ scales: {
+ x: {
+ type: "time",
+ time: { unit: filter.range },
+ ticks: { source: "labels" },
+ stacked: true,
+ title: { display: true, text: i18n.str`Time range`, align: "end" },
+ },
+ y: {
+ stacked: true,
+ title: { display: true, text: filter.currency, align: "end" },
+ },
+ },
+ };
+}
+
+function filterRevenueCharData(
+ colors: Map<MerchantRevenueStatsSlug, string>,
+ revenueStats: Map<MerchantRevenueStatsSlug, StatisticsAmount>,
+ filter: RevenueChartFilter,
+): {
+ datasets: ChartDataset<"bar", (Point & AmountJson)[]>[];
+ labels: number[];
+} {
+ const datasets: ChartDataset<"bar", (Point & AmountJson)[]>[] = [];
+ const labels: number[] = [];
+
+ revenueStats.forEach((stat, slug) => {
+ // var datasetForCurrency = new Map<string, ChartDataset<"line" | "bar">>();
+ const datasetColor = colors.get(slug ?? "") ?? "#eeeeee";
+ const info: ChartDataset<"bar", (Point & AmountJson)[]> = {
+ label: stat.buckets_description,
+ data: [],
+ backgroundColor: datasetColor,
+ };
+ for (let b of stat.buckets) {
+ if (b.start_time.t_s == "never") {
+ continue;
+ }
+
+ for (let c of b.cumulative_amounts) {
+ const a = Amounts.parse(c);
+ if (!a) {
+ continue;
+ }
+ if (filter.range !== b.range) {
+ continue;
+ }
+ const amount = a.value + a.fraction / Math.pow(10, 8);
+ const timeRangeStart = b.start_time.t_s * 1000;
+ info.data.push({ x: timeRangeStart, y: amount, ...a });
+ if (filter.rangeCount < info.data.length) {
+ info.data.shift(); // Remove oldest entry
+ labels.shift(); // Remove oldest entry
+ }
+ if (labels.indexOf(timeRangeStart) === -1) {
+ labels.push(timeRangeStart);
+ }
+ }
+ }
+ if (info.data.length) {
+ datasets.push(info);
+ }
+ });
+ return { datasets, labels };
+}
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
@@ -21,362 +21,95 @@
import {
AbsoluteTime,
- Amounts,
- HttpStatusCode,
- StatisticsAmount,
- TalerError,
- assertUnreachable,
- StatisticsCounter,
- AmountJson,
- StatisticBucketRange,
+ Duration,
+ StatisticBucketRange
} from "@gnu-taler/taler-util";
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { useState } from "preact/hooks";
-import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js";
-import { Loading } from "../../../../components/exception/loading.js";
-import { NotificationCard } from "../../../../components/menu/index.js";
-import { Notification } from "../../../../utils/types.js";
-import { LoginPage } from "../../../login/index.js";
+import "chartjs-adapter-date-fns";
+import { useSessionContext } from "../../../../context/session.js";
import {
- Chart as ChartJS,
- CategoryScale,
- LinearScale,
- BarElement,
- PointElement,
- LineElement,
- ChartDataset,
- Title,
- TimeScale,
- Tooltip,
- Legend,
- Filler,
- Point,
- ChartOptions,
- LineOptions,
- ComplexFillTarget,
-} from 'chart.js';
-import 'chartjs-adapter-date-fns';
-import { RevenueChart, RevenueChartFilter } from "./RevenueChart.js";
+ MerchantOrderStatsSlug,
+ MerchantRevenueStatsSlug
+} from "../../../../hooks/statistics.js";
import { OrdersChart } from "./OrdersChart.js";
-import { useInstanceStatisticsAmount, useInstanceStatisticsCounter } from "../../../../hooks/statistics.js";
-import { sub } from "date-fns";
-import { addTestValues } from "./testing.js";
-interface Props {
-}
+import { RevenueChart } from "./RevenueChart.js";
+interface Props {}
-ChartJS.register(
- CategoryScale,
- LinearScale,
- TimeScale,
- LineElement,
- PointElement,
- BarElement,
- Title,
- Filler,
- Tooltip,
- Legend
-);
export interface StatSlug {
- slug: string,
- text: string
+ slug: string;
+ text: string;
}
-const chartColors = new Map<string, string>();
- chartColors.set("orders-created", '#b9a5ff');
- chartColors.set("tokens-issued", '#b9a5ff');
- chartColors.set("payments-received-after-deposit-fee", '#b9a5ff');
- chartColors.set("orders-claimed", '#647cda');
- chartColors.set("total-wire-fees-paid", '#647cda');
- chartColors.set("total-deposit-fees-paid", '#2830a8');
- chartColors.set("orders-settled", '#2830a8');
- chartColors.set("tokens-used", '#2830a8');
- chartColors.set("orders-paid", '#525597');
- chartColors.set("refunds-granted", '#525597');
-
-export default function Statistics({
-}: Props): VNode {
- const [notif, _] = useState<Notification | undefined>(undefined);
- const ordersStats = new Map<string, StatisticsCounter>();
- const revenueStats = new Map<string, StatisticsAmount>();
-
- const currentDate = new Date();
- const lastMonthDate = sub(currentDate, { months: 1 });
- const lastMonthAbs: AbsoluteTime = {t_ms: lastMonthDate.getMilliseconds() } as AbsoluteTime;
- const [startOrdersFromDate, setStartOrdersFromDate] = useState<AbsoluteTime | undefined>(lastMonthAbs);
- const [revenueChartFilter, setRevenueChartFilter] = useState<RevenueChartFilter>({
- range: StatisticBucketRange.Quarter,
- rangeCount: 4,
- currency: "", // FIXME get this from merchant?
- });
-
- for (let ordersSlug of ["orders-created", "orders-claimed", "orders-paid", "orders-settled"]) {
- let res = useInstanceStatisticsCounter(ordersSlug);
- if (!res) return <Loading />;
- if (res instanceof TalerError) {
- return <ErrorLoadingMerchant error={res} />;
- }
- if (res.type === "fail") {
- switch (res.case) {
- case HttpStatusCode.Unauthorized: {
- return <LoginPage />;
- }
- case HttpStatusCode.BadGateway: {
- return <div />;
- }
- case HttpStatusCode.ServiceUnavailable: {
- return <div />;
- }
- case HttpStatusCode.Unauthorized: {
- return <div />;
- }
- case HttpStatusCode.NotFound: {
- return <div />;
- }
- default: {
- assertUnreachable(res);
- }
- }
- }
- ordersStats.set(ordersSlug, res.body);
- }
- for (let revenueSlug of ["payments-received-after-deposit-fee",
- "total-wire-fees-paid",
- "refunds-granted",
- "total-deposit-fees-paid"]) {
- let res = useInstanceStatisticsAmount(revenueSlug);
- if (!res) return <Loading />;
- if (res instanceof TalerError) {
- return <ErrorLoadingMerchant error={res} />;
- }
- if (res.type === "fail") {
- switch (res.case) {
- case HttpStatusCode.Unauthorized: {
- return <LoginPage />;
- }
- case HttpStatusCode.BadGateway: {
- return <div />;
- }
- case HttpStatusCode.ServiceUnavailable: {
- return <div />;
- }
- case HttpStatusCode.Unauthorized: {
- return <div />;
- }
- case HttpStatusCode.NotFound: {
- return <div />;
- }
- default: {
- assertUnreachable(res);
- }
- }
- }
- revenueStats.set(revenueSlug, res.body);
- }
- //if (true) {
- // addTestValues(ordersStats, revenueStats);
- // console.log(revenueStats);
- //}
-
- const orderStatsChartOptions = {
- plugins: {
- title: {
- display: true,
- text: 'Orders',
- },
- filler: {
- propagate: true
- }
- },
- responsive: true,
- scales: {
- x: {
- min: startOrdersFromDate?.t_ms,
- type: "time",
- time: { unit: "day", displayFormats: { day: "Pp" }},
- ticks: { source: "data"},
- stacked: true,
- title: { display: false }
- },
- y: {
- stacked: true,
- title: { display: true, text: "# of orders since", align: "end" }
- },
- },
- };
- const revenueChartOptions = {
- plugins: {
- title: {
- display: true,
- text: 'Revenue',
- },
- },
- responsive: true,
- scales: {
- x: {
- type: "time",
- time: { unit: "quarter" },
- ticks: { source: "labels"},
- stacked: true,
- title: { display: true, text: "Time range", align: "end" }
- },
- y: {
- stacked: true,
- title: { display: true, text: "Euro (€)", align: "end" }
- },
- },
- };
- var revenueChartDatasets = new Map<string, ChartDataset[]>();
- var orderStatsChartDatasets: ChartDataset[] = [];
-
- const revenueChartLabels = new Map<string, number[]>();
- const revenueCurrencies: string[] = [];
- revenueStats.forEach((stat: StatisticsAmount, slug: string) => {
- var datasetForCurrency = new Map<string, ChartDataset>();
- for (let b of stat.buckets) {
- // We collect all currencies that we have stats of before we
- // filter.
- // FIXME kind of ugly.
- // We probably want to build all datasets here and filter later.
- b.cumulative_amounts.map ((c) => {
- const a = Amounts.parse(c);
- if (a && revenueCurrencies.indexOf(a.currency) === -1) {
- revenueCurrencies.push(a.currency);
- }
- });
- if (b.start_time.t_s == "never") {
- continue;
- }
+const chartColors = new Map<
+ MerchantOrderStatsSlug | MerchantRevenueStatsSlug,
+ string
+>();
+chartColors.set("orders-created", "#b9a5ff");
+// chartColors.set("tokens-issued", "#b9a5ff");
+chartColors.set("payments-received-after-deposit-fee", "#b9a5ff");
+chartColors.set("orders-claimed", "#647cda");
+chartColors.set("total-wire-fees-paid", "#647cda");
+chartColors.set("total-deposit-fees-paid", "#2830a8");
+chartColors.set("orders-settled", "#2830a8");
+// chartColors.set("tokens-used", "#2830a8");
+chartColors.set("orders-paid", "#525597");
+chartColors.set("refunds-granted", "#525597");
+
+export interface RevenueChartFilter {
+ rangeCount: number;
+ range: RelevantTimeUnit;
+ currency: string;
+}
- for (let c of b.cumulative_amounts) {
- const a = Amounts.parse(c);
- if (!a) {
- continue;
- }
- // If unset, set to the first currency we find.
- if ("" === revenueChartFilter.currency) {
- revenueChartFilter.currency = a.currency;
- }
- if (revenueChartFilter.range !== b.range) {
- continue;
- }
- const datasetColor = chartColors.get(slug ?? "") ?? "#eeeeee";
- if (!datasetForCurrency.has(a.currency)) {
- datasetForCurrency.set(a.currency, {
- label: stat.buckets_description,
- data: Array<Point & AmountJson>(),
- backgroundColor: datasetColor,
- hoverOffset: 4,
- })
- }
- const amount = a.value + (a.fraction / Math.pow(10, 8));
- const timeRangeStart = b.start_time.t_s * 1000;
- datasetForCurrency.get(a.currency)!.data.push({x: timeRangeStart, y: amount, ...a});
- if (revenueChartFilter.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, []);
- }
- const labels = revenueChartLabels.get(a.currency);
- if (labels?.indexOf(timeRangeStart) === -1) {
- labels?.push(timeRangeStart);
- }
- }
- }
- datasetForCurrency.forEach((value: ChartDataset, key: string) => {
- if (!revenueChartDatasets.has(key)) {
- revenueChartDatasets.set(key, []);
- }
- revenueChartDatasets.get(key)?.push(value);
- })
- });
- var smallestOrderDate: number = -1;
- const orderStatsChartLabels: number[] = [];
- ordersStats.forEach((stat: StatisticsCounter, slug: string) => {
- const datasetColor = chartColors.get(slug ?? "") ?? "#eeeeee";
- const dataset: ChartDataset = {
- label: stat.intervals_description,
- data: Array<Point>(),
- backgroundColor: datasetColor,
- hoverOffset: 4,
- fill: {
- target: "-1",
- below: datasetColor,
- }
- }
- var accum = 0;
- for (let j = stat.intervals.length - 1; j >= 0; j--) {
- const interval = stat.intervals[j];
- if (interval.start_time.t_s == "never") {
- continue;
- }
- accum += interval.cumulative_counter;
- dataset.data.push({x: interval.start_time.t_s * 1000, y: accum});
- // Do not add label if outside of range
- const intervalStart = interval.start_time.t_s * 1000;
- if ((startOrdersFromDate) &&
- ("never" !== startOrdersFromDate.t_ms) &&
- (intervalStart < startOrdersFromDate.t_ms)) {
- continue;
- }
- if (-1 === orderStatsChartLabels.indexOf(intervalStart)) {
- orderStatsChartLabels.push(intervalStart);
- }
- if (smallestOrderDate < 0) {
- smallestOrderDate = intervalStart;
- }
- if (smallestOrderDate > intervalStart) {
- smallestOrderDate = intervalStart;
- }
- }
- orderStatsChartDatasets.push(dataset);
- });
- orderStatsChartLabels.sort();
- // Hack to make first area to origin work.
- const ldo = orderStatsChartDatasets[orderStatsChartDatasets.length - 1] as LineOptions;
- (ldo.fill as ComplexFillTarget).target = "origin";
- // Set the orders from date to the smallest interval we found
- if (startOrdersFromDate && smallestOrderDate && (startOrdersFromDate.t_ms != "never") && (startOrdersFromDate.t_ms < smallestOrderDate)) {
- setStartOrdersFromDate({ t_ms: smallestOrderDate } as AbsoluteTime);
- }
- orderStatsChartDatasets = orderStatsChartDatasets.reverse();
+export type RelevantTimeUnit =
+ | "hour"
+ | "day"
+ | "week"
+ | "month"
+ | "quarter"
+ | "year";
+
+export default function Statistics({}: Props): VNode {
+ const { config } = useSessionContext();
+ const lastMonthAbs = AbsoluteTime.subtractDuraction(
+ AbsoluteTime.now(),
+ Duration.fromSpec({ months: 1 }),
+ );
- // FIXME: End date picker?
- // FIXME: Intelligent date range selection?
- revenueChartOptions.scales.y.title.text = revenueChartFilter.currency;
- revenueChartOptions.scales.x.time.unit = revenueChartFilter.range;
- const a: VNode = (
+ const [revenueChartFilter, setRevenueChartFilter] =
+ useState<RevenueChartFilter>({
+ range: StatisticBucketRange.Quarter,
+ rangeCount: 4,
+ currency: config.currency,
+ });
+ const [startOrdersFromDate, setStartOrdersFromDate] = useState<
+ AbsoluteTime | undefined
+ >(lastMonthAbs);
+
+ const availableCurrencies = Object.keys(config.currencies);
+ return (
<section class="section is-main-section">
- <NotificationCard notification={notif} />
-
- <div>
- <RevenueChart
- chartLabels={revenueChartLabels.get(revenueChartFilter.currency)?.sort().reverse() ?? []}
- chartData={revenueChartDatasets.get(revenueChartFilter.currency)}
- chartOptions={revenueChartOptions as ChartOptions}
- activeFilter={revenueChartFilter}
- availableCurrencies={revenueCurrencies}
- onUpdateFilter={(filter: RevenueChartFilter) => setRevenueChartFilter(filter)}
+ <div>
+ <RevenueChart
+ colors={chartColors as Map<MerchantRevenueStatsSlug, string>}
+ activeFilter={revenueChartFilter}
+ availableCurrencies={availableCurrencies}
+ onUpdateFilter={(filter: RevenueChartFilter) =>
+ setRevenueChartFilter(filter)
+ }
/>
- </div>
- <hr/>
- <OrdersChart
- chartLabels={orderStatsChartLabels.reverse()}
- chartData={orderStatsChartDatasets}
- chartOptions={orderStatsChartOptions}
- filterFromDate={startOrdersFromDate}
- onSelectDate={(d?: AbsoluteTime) => { setStartOrdersFromDate(d)}}
+ </div>
+ <hr />
+ <OrdersChart
+ colors={chartColors as Map<MerchantOrderStatsSlug, string>}
+ filterFromDate={startOrdersFromDate}
+ onSelectDate={(d?: AbsoluteTime) => {
+ setStartOrdersFromDate(d);
+ }}
/>
</section>
);
- return a;
}
-
-
-
-
-
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -3149,7 +3149,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp
async getStatisticsCounter(
token: AccessToken | undefined,
statSlug: string,
- params: TalerMerchantApi.GetStatisticsRequestParams,
+ params: TalerMerchantApi.GetStatisticsRequestParams = {},
) {
const url = new URL(`private/statistics-counter/${statSlug}`, this.baseUrl);
@@ -3186,7 +3186,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp
async getStatisticsAmount(
token: AccessToken | undefined,
statSlug: string,
- params: TalerMerchantApi.GetStatisticsRequestParams,
+ params: TalerMerchantApi.GetStatisticsRequestParams = {},
) {
const url = new URL(`private/statistics-amount/${statSlug}`, this.baseUrl);
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -1596,7 +1596,7 @@ export interface GetStatisticsRequestParams {
// be returned. If set to “INTERVAL”, only statistics kept by
// interval will be returned.
// If not set or set to “ANY”, both will be returned.
- by?: string;
+ by?: "INTEVAL" | "BUCKET" | undefined;
}
export interface PayRequest {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -727,9 +727,6 @@ importers:
qrcode-generator:
specifier: 1.4.4
version: 1.4.4
- react-chartjs-2:
- specifier: ^5.3.1
- version: 5.3.1(chart.js@4.5.0)(react@18.3.1)
swr:
specifier: 2.2.2
version: 2.2.2(react@18.3.1)
@@ -8605,12 +8602,6 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
- react-chartjs-2@5.3.1:
- resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==}
- peerDependencies:
- chart.js: ^4.1.1
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
-
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -20128,11 +20119,6 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
- react-chartjs-2@5.3.1(chart.js@4.5.0)(react@18.3.1):
- dependencies:
- chart.js: 4.5.0
- react: 18.3.1
-
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0