diff options
Diffstat (limited to 'packages/bank-ui/src/pages/admin/AdminHome.tsx')
-rw-r--r-- | packages/bank-ui/src/pages/admin/AdminHome.tsx | 623 |
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> + ); +} |