summaryrefslogtreecommitdiff
path: root/packages/bank-ui/src/pages/admin/AdminHome.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/bank-ui/src/pages/admin/AdminHome.tsx')
-rw-r--r--packages/bank-ui/src/pages/admin/AdminHome.tsx623
1 files changed, 623 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx
new file mode 100644
index 000000000..acae09b40
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/AdminHome.tsx
@@ -0,0 +1,623 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Amounts,
+ CurrencySpecification,
+ HttpStatusCode,
+ TalerCorebankApi,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ RouteDefinition,
+ useBankCoreApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import {
+ format,
+ sub
+} from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { Transactions } from "../../components/Transactions/index.js";
+import { useConversionInfo, useLastMonitorInfo } from "../../hooks/regional.js";
+import { RenderAmount } from "../PaytoWireTransferForm.js";
+import { WireTransfer } from "../WireTransfer.js";
+import { AccountList } from "./AccountList.js";
+
+/**
+ * Query account information and show QR code if there is pending withdrawal
+ */
+interface Props {
+ routeCreate: RouteDefinition;
+ routeDownloadStats: RouteDefinition;
+ routeCreateWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+
+ routeShowAccount: RouteDefinition<{ account: string }>;
+ routeRemoveAccount: RouteDefinition<{ account: string }>;
+ routeUpdatePasswordAccount: RouteDefinition<{ account: string }>;
+ routeShowCashoutsAccount: RouteDefinition<{ account: string }>;
+ onAuthorizationRequired: () => void;
+}
+export function AdminHome({
+ routeCreate,
+ routeRemoveAccount,
+ routeShowAccount,
+ routeUpdatePasswordAccount,
+ routeDownloadStats,
+ routeCreateWireTransfer,
+ onAuthorizationRequired,
+}: Props): VNode {
+ return (
+ <Fragment>
+ <Metrics routeDownloadStats={routeDownloadStats} />
+ <WireTransfer
+ routeHere={routeCreateWireTransfer}
+ onAuthorizationRequired={onAuthorizationRequired}
+ />
+ <Transactions
+ account="admin"
+ routeCreateWireTransfer={routeCreateWireTransfer}
+ />
+ <AccountList
+ routeCreate={routeCreate}
+ routeRemoveAccount={routeRemoveAccount}
+ routeShowAccount={routeShowAccount}
+ routeUpdatePasswordAccount={routeUpdatePasswordAccount}
+ />
+ </Fragment>
+ );
+}
+
+function getDateForTimeframe(
+ date: AbsoluteTime,
+ timeframe: TalerCorebankApi.MonitorTimeframeParam,
+ locale: Locale,
+): string {
+ if (date.t_ms === "never") return "--";
+ switch (timeframe) {
+ case TalerCorebankApi.MonitorTimeframeParam.hour:
+ return `${format(date.t_ms, "HH", { locale })}hs`;
+ case TalerCorebankApi.MonitorTimeframeParam.day:
+ return format(date.t_ms, "EEEE", { locale });
+ case TalerCorebankApi.MonitorTimeframeParam.month:
+ return format(date.t_ms, "MMMM", { locale });
+ case TalerCorebankApi.MonitorTimeframeParam.year:
+ return format(date.t_ms, "yyyy", { locale });
+ case TalerCorebankApi.MonitorTimeframeParam.decade:
+ return format(date.t_ms, "yyyy", { locale });
+ }
+ assertUnreachable(timeframe);
+}
+
+export function getTimeframesForDate(
+ time: Date,
+ timeframe: TalerCorebankApi.MonitorTimeframeParam,
+): { current: AbsoluteTime; previous: AbsoluteTime } {
+ switch (timeframe) {
+ case TalerCorebankApi.MonitorTimeframeParam.hour:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { hours: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { hours: 2 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.day:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { days: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { days: 4 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.month:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { months: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { months: 2 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.year:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 2 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.decade:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 10 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 20 }).getTime(),
+ ),
+ };
+ default:
+ assertUnreachable(timeframe);
+ }
+}
+
+function Metrics({
+ routeDownloadStats,
+}: {
+ routeDownloadStats: RouteDefinition;
+}): VNode {
+ const { i18n, dateLocale } = useTranslationContext();
+ const [metricType, setMetricType] =
+ useState<TalerCorebankApi.MonitorTimeframeParam>(
+ TalerCorebankApi.MonitorTimeframeParam.hour,
+ );
+ const { config } = useBankCoreApiContext();
+ const respInfo = useConversionInfo();
+ const params = getTimeframesForDate(new Date(), metricType);
+
+ const resp = useLastMonitorInfo(params.current, params.previous, metricType);
+ if (!resp) return <Fragment />;
+ if (resp instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resp} />;
+ }
+ if (!respInfo) return <Fragment />;
+ if (respInfo instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={respInfo} />;
+ }
+ if (respInfo.type === "fail") {
+ switch (respInfo.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default: {
+ assertUnreachable(respInfo.case);
+ }
+ }
+ }
+
+ if (resp.current.type !== "ok") {
+ switch (resp.current.case) {
+ case HttpStatusCode.BadRequest:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the current stats failed`}
+ >
+ <i18n.Translate>The request parameters are wrong</i18n.Translate>
+ </Attention>
+ );
+ case HttpStatusCode.Unauthorized:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the current stats failed`}
+ >
+ <i18n.Translate>The user is unauthorized</i18n.Translate>
+ </Attention>
+ );
+ default: {
+ assertUnreachable(resp.current);
+ }
+ }
+ }
+ if (resp.previous.type !== "ok") {
+ switch (resp.previous.case) {
+ case HttpStatusCode.BadRequest:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the previous stats failed`}
+ >
+ <i18n.Translate>The request parameters are wrong</i18n.Translate>
+ </Attention>
+ );
+ case HttpStatusCode.Unauthorized:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the previous stats failed`}
+ >
+ <i18n.Translate>The user is unauthorized</i18n.Translate>
+ </Attention>
+ );
+ default: {
+ assertUnreachable(resp.previous);
+ }
+ }
+ }
+ return (
+ <div class="px-4 mt-4">
+ <div class="sm:flex sm:items-center mb-4">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Transaction volume report</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+
+ <div class="sm:hidden">
+ <label for="tabs" class="sr-only">
+ <i18n.Translate>Select a section</i18n.Translate>
+ </label>
+ <select
+ id="tabs"
+ name="tabs"
+ class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
+ onChange={(e) => {
+ // const op = e.currentTarget.value as typeof metricType
+ setMetricType(
+ e.currentTarget
+ .value as unknown as TalerCorebankApi.MonitorTimeframeParam,
+ );
+ }}
+ >
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.hour}
+ selected={metricType == TalerCorebankApi.MonitorTimeframeParam.hour}
+ >
+ <i18n.Translate>Last hour</i18n.Translate>
+ </option>
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.day}
+ selected={metricType == TalerCorebankApi.MonitorTimeframeParam.day}
+ >
+ <i18n.Translate>Previous day</i18n.Translate>
+ </option>
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.month}
+ selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.month
+ }
+ >
+ <i18n.Translate>Last month</i18n.Translate>
+ </option>
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.year}
+ selected={metricType == TalerCorebankApi.MonitorTimeframeParam.year}
+ >
+ <i18n.Translate>Last year</i18n.Translate>
+ </option>
+ </select>
+ </div>
+ <div class="hidden sm:block">
+ {/* FIXME: This should be LINKS */}
+ <nav
+ class="isolate flex divide-x divide-gray-200 rounded-lg shadow"
+ aria-label="Tabs"
+ >
+ <button
+ type="button"
+ name="set last hour"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.hour);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.hour
+ }
+ class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Last hour</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.hour
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ <button
+ type="button"
+ name="set previous day"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.day);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.day
+ }
+ class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Previous day</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.day
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ <button
+ type="button"
+ name="set last month"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.month);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.month
+ }
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Last month</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.month
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ <button
+ type="button"
+ name="set last year"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.year);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.year
+ }
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Last Year</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.year
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ </nav>
+ </div>
+
+ <div class="w-full flex justify-between">
+ <h1 class="text-base text-gray-900 mt-5">
+ {i18n.str`Trading volume on ${getDateForTimeframe(
+ params.current,
+ metricType,
+ dateLocale,
+ )} compared to ${getDateForTimeframe(
+ params.previous,
+ metricType,
+ dateLocale,
+ )}`}
+ </h1>
+ </div>
+ <dl class="mt-5 grid grid-cols-1 md:grid-cols-2 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0">
+ {resp.current.body.type !== "with-conversions" ||
+ resp.previous.body.type !== "with-conversions" ? undefined : (
+ <Fragment>
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Cashin</i18n.Translate>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from an external account to an account in this
+ bank.
+ </i18n.Translate>
+ </div>
+ </dt>
+ <MetricValue
+ current={resp.current.body.cashinFiatVolume}
+ previous={resp.previous.body.cashinFiatVolume}
+ spec={respInfo.body.fiat_currency_specification}
+ />
+ </div>
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Cashout</i18n.Translate>
+ </dt>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from an account in this bank to an external
+ account.
+ </i18n.Translate>
+ </div>
+ <MetricValue
+ current={resp.current.body.cashoutFiatVolume}
+ previous={resp.previous.body.cashoutFiatVolume}
+ spec={respInfo.body.fiat_currency_specification}
+ />
+ </div>
+ </Fragment>
+ )}
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Payin</i18n.Translate>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from an account to a Taler exchange.
+ </i18n.Translate>
+ </div>
+ </dt>
+ <MetricValue
+ current={resp.current.body.talerInVolume}
+ previous={resp.previous.body.talerInVolume}
+ spec={config.currency_specification}
+ />
+ </div>
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Payout</i18n.Translate>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from a Taler exchange to another account.
+ </i18n.Translate>
+ </div>
+ </dt>
+ <MetricValue
+ current={resp.current.body.talerOutVolume}
+ previous={resp.previous.body.talerOutVolume}
+ spec={config.currency_specification}
+ />
+ </div>
+ </dl>
+ <div class="flex justify-end mt-4">
+ <a
+ href={routeDownloadStats.url({})}
+ name="download stats"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Download stats as CSV</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ );
+}
+
+function MetricValue({
+ current,
+ previous,
+ spec,
+}: {
+ spec: CurrencySpecification;
+ current: AmountString | undefined;
+ previous: AmountString | undefined;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const cmp = current && previous ? Amounts.cmp(current, previous) : 0;
+ const cv = !current ? undefined : Amounts.stringifyValue(current);
+ const currAmount = !cv ? undefined : Number.parseFloat(cv);
+ const prevAmount = !previous
+ ? undefined
+ : Number.parseFloat(Amounts.stringifyValue(previous));
+
+ const rate =
+ !currAmount ||
+ Number.isNaN(currAmount) ||
+ !prevAmount ||
+ Number.isNaN(prevAmount)
+ ? 0
+ : cmp === -1
+ ? 1 - Math.round(currAmount) / Math.round(prevAmount)
+ : cmp === 1
+ ? Math.round(currAmount) / Math.round(prevAmount) - 1
+ : 0;
+
+ const negative = cmp === 0 ? undefined : cmp === -1;
+ const rateStr = `${(Math.abs(rate) * 100).toFixed(2)}%`;
+ return (
+ <Fragment>
+ <dd class="mt-1 block ">
+ <div class="flex justify-start text-2xl items-baseline font-semibold text-indigo-600">
+ {!current ? (
+ "-"
+ ) : (
+ <RenderAmount
+ value={Amounts.parseOrThrow(current)}
+ spec={spec}
+ hideSmall
+ />
+ )}
+ </div>
+ <div class="flex flex-col">
+ <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600">
+ <small class="ml-2 text-sm font-medium text-gray-500">
+ <i18n.Translate>from</i18n.Translate>{" "}
+ {!previous ? (
+ "-"
+ ) : (
+ <RenderAmount
+ value={Amounts.parseOrThrow(previous)}
+ spec={spec}
+ hideSmall
+ />
+ )}
+ </small>
+ </div>
+ {!!rate && (
+ <span
+ data-negative={negative}
+ class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 text-green-800 data-[negative=true]:bg-red-100 px-2 py-1 text-xs font-medium data-[negative=true]:text-red-700 whitespace-pre"
+ >
+ {negative ? (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75"
+ />
+ </svg>
+ ) : (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75"
+ />
+ </svg>
+ )}
+
+ {negative ? (
+ <span class="sr-only">
+ <i18n.Translate>Decreased by</i18n.Translate>
+ </span>
+ ) : (
+ <span class="sr-only">
+ <i18n.Translate>Increased by</i18n.Translate>
+ </span>
+ )}
+ {rateStr}
+ </span>
+ )}
+ </div>
+ </dd>
+ </Fragment>
+ );
+}