taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/merchant-backoffice-ui/package.json | 1-
Apackages/merchant-backoffice-ui/src/components/ChartJS.tsx | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/hooks/statistics.ts | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mpackages/merchant-backoffice-ui/src/paths/instance/statistics/list/OrdersChart.tsx | 366+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/statistics/list/RevenueChart.tsx | 475++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx | 413++++++++++++++-----------------------------------------------------------------
Mpackages/taler-util/src/http-client/merchant.ts | 4++--
Mpackages/taler-util/src/types-taler-merchant.ts | 2+-
Mpnpm-lock.yaml | 14--------------
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