gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-typescript-core] 02/02: dashboard


From: gnunet
Subject: [taler-typescript-core] 02/02: dashboard
Date: Tue, 21 Jan 2025 22:17:22 +0100

This is an automated email from the git hooks/post-receive script.

sebasjm pushed a commit to branch master
in repository taler-typescript-core.

commit 8d834d43c51f58140e31581656a1a4cfad90aea6
Author: Sebastian <sebasjm@gmail.com>
AuthorDate: Tue Jan 21 18:17:07 2025 -0300

    dashboard
---
 .../aml-backoffice-ui/src/ExchangeAmlFrame.tsx     |   9 +-
 packages/aml-backoffice-ui/src/Routing.tsx         |  11 +-
 packages/aml-backoffice-ui/src/hooks/account.ts    |  32 --
 .../src/hooks/decision-request.ts                  |   9 +-
 .../src/hooks/{account.ts => server-info.ts}       |  90 +++-
 .../aml-backoffice-ui/src/pages/CaseDetails.tsx    | 104 ++--
 packages/aml-backoffice-ui/src/pages/Dashboard.tsx | 570 +++++++++++++++++++++
 packages/aml-backoffice-ui/src/pages/Measures.tsx  |   5 +-
 .../pages/decision/AmlDecisionRequestWizard.tsx    |  12 +-
 .../src/pages/decision/Events.tsx                  | 188 +++----
 .../src/pages/decision/Justification.tsx           | 119 ++++-
 .../src/pages/decision/Measures.tsx                |  16 +-
 .../src/pages/decision/Properties.tsx              |  21 +-
 .../aml-backoffice-ui/src/pages/decision/Rules.tsx |  16 +-
 .../decision/{Justification.tsx => Summary.tsx}    |  15 +-
 .../src/pages/decision/aml-events.ts               | 133 +++++
 packages/bank-ui/src/hooks/regional.ts             |  25 +-
 packages/bank-ui/src/pages/admin/AdminHome.tsx     | 138 ++++-
 packages/taler-util/src/http-client/exchange.ts    |  13 +-
 packages/taler-util/src/types-taler-exchange.ts    |  26 +-
 20 files changed, 1266 insertions(+), 286 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx 
b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
index 8b5911641..2cd82505f 100644
--- a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
+++ b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
@@ -218,7 +218,7 @@ export function ExchangeAmlFrame({
 
       <div class="-mt-32 flex grow ">
         {officer?.state !== "ready" ? undefined : <Navigation />}
-        <div class="flex mx-auto my-4">
+        <div class="flex mx-auto my-4 min-w-80">
           <main
             class="block rounded-lg bg-white px-5 py-6 shadow "
             style={{ minWidth: 600 }}
@@ -241,7 +241,11 @@ function Navigation(): VNode {
   const { i18n } = useTranslationContext();
   const [{ showDebugInfo }] = usePreferences();
   const pageList = [
-    { route: privatePages.profile, Icon: PeopleIcon, label: i18n.str`Profile` 
},
+    {
+      route: privatePages.dashboard,
+      Icon: PeopleIcon,
+      label: i18n.str`Dashboard`,
+    },
     {
       route: privatePages.investigation,
       Icon: ToInvestigateIcon,
@@ -253,6 +257,7 @@ function Navigation(): VNode {
       Icon: SearchIcon,
       label: i18n.str`Search`,
     },
+    { route: privatePages.profile, Icon: PeopleIcon, label: i18n.str`Profile` 
},
     showDebugInfo
       ? { route: privatePages.measures, Icon: FormIcon, label: i18n.str`Forms` 
}
       : undefined,
diff --git a/packages/aml-backoffice-ui/src/Routing.tsx 
b/packages/aml-backoffice-ui/src/Routing.tsx
index 1358eda50..58a0730f1 100644
--- a/packages/aml-backoffice-ui/src/Routing.tsx
+++ b/packages/aml-backoffice-ui/src/Routing.tsx
@@ -43,6 +43,7 @@ import {
   WizardSteps,
 } from "./pages/decision/AmlDecisionRequestWizard.js";
 import { useCurrentDecisionRequest } from "./hooks/decision-request.js";
+import { Dashboard } from "./pages/Dashboard.js";
 
 export function Routing(): VNode {
   const session = useOfficer();
@@ -107,6 +108,8 @@ function PublicRounting(): VNode {
 
 export const privatePages = {
   profile: urlPattern(/\/profile/, () => "#/profile"),
+  dashboard: urlPattern(/\/dashboard/, () => "#/dashboard"),
+  statsDownload: urlPattern(/\/download-stats/, () => "#/download-stats"),
   decideWithStep: urlPattern<{ cid: string; step: string }>(
     /\/decide\/(?<cid>[a-zA-Z0-9]+)\/(?<step>[a-z]+)/,
     ({ cid, step }) => `#/decide/${cid}/${step}`,
@@ -147,7 +150,7 @@ function PrivateRouting(): VNode {
   const [request, _, updateRequest] = useCurrentDecisionRequest();
   useEffect(() => {
     if (location.name === undefined) {
-      navigateTo(privatePages.profile.url({}));
+      navigateTo(privatePages.dashboard.url({}));
     }
   }, [location]);
 
@@ -261,6 +264,12 @@ function PrivateRouting(): VNode {
     case "search": {
       return <Search />;
     }
+    case "statsDownload": {
+      return <div>not yet implemented</div>;
+    }
+    case "dashboard": {
+      return <Dashboard routeDownloadStats={privatePages.statsDownload} />;
+    }
     default:
       assertUnreachable(location);
   }
diff --git a/packages/aml-backoffice-ui/src/hooks/account.ts 
b/packages/aml-backoffice-ui/src/hooks/account.ts
index a4fbcb803..f34da0825 100644
--- a/packages/aml-backoffice-ui/src/hooks/account.ts
+++ b/packages/aml-backoffice-ui/src/hooks/account.ts
@@ -68,35 +68,3 @@ export function useAccountInformation(paytoHash?: string) {
   if (error) return error;
   return undefined;
 }
-
-export function useServerMeasures() {
-  const officer = useOfficer();
-  const session = officer.state === "ready" ? officer.account : undefined;
-
-  const {
-    lib: { exchange: api },
-  } = useExchangeApiContext();
-
-  async function fetcher([officer]: [OfficerAccount]) {
-    return await api.getAmlMesasures(officer);
-  }
-
-  const { data, error } = useSWR<
-    TalerExchangeResultByMethod<"getAmlMesasures">,
-    TalerHttpError
-  >(!session ? undefined : [session], fetcher, {
-    refreshInterval: 0,
-    refreshWhenHidden: false,
-    revalidateOnFocus: false,
-    revalidateOnReconnect: false,
-    refreshWhenOffline: false,
-    errorRetryCount: 0,
-    errorRetryInterval: 1,
-    shouldRetryOnError: false,
-    keepPreviousData: true,
-  });
-
-  if (data) return data;
-  if (error) return error;
-  return undefined;
-}
diff --git a/packages/aml-backoffice-ui/src/hooks/decision-request.ts 
b/packages/aml-backoffice-ui/src/hooks/decision-request.ts
index 9b67b5654..34c2aeda3 100644
--- a/packages/aml-backoffice-ui/src/hooks/decision-request.ts
+++ b/packages/aml-backoffice-ui/src/hooks/decision-request.ts
@@ -35,14 +35,15 @@ import { buildStorageKey, useLocalStorage } from 
"@gnu-taler/web-util/browser";
 
 export interface DecisionRequest {
   rules: KycRule[] | undefined;
+  new_measures: string | undefined;
   deadline: AbsoluteTime | undefined;
-  properties: object | undefined;
-  custom_properties: object | undefined;
+  onExpire_measures: string | undefined;
+  properties: Record<string, any> | undefined;
+  custom_properties: Record<string, any> | undefined;
   custom_events: string[] | undefined;
   inhibit_events: string[] | undefined;
   keep_investigating: boolean;
   justification: string | undefined;
-  new_measures: string | undefined;
 }
 
 export const codecForDecisionRequest = (): Codec<DecisionRequest> =>
@@ -59,10 +60,12 @@ export const codecForDecisionRequest = (): 
Codec<DecisionRequest> =>
       codecOptionalDefault(codecForBoolean(), false),
     )
     .property("new_measures", codecOptional(codecForString()))
+    .property("onExpire_measures", codecOptional(codecForString()))
     .build("DecisionRequest");
 
 const defaultDecisionRequest: DecisionRequest = {
   deadline: undefined,
+  onExpire_measures: undefined,
   custom_events: undefined,
   inhibit_events: undefined,
   justification: undefined,
diff --git a/packages/aml-backoffice-ui/src/hooks/account.ts 
b/packages/aml-backoffice-ui/src/hooks/server-info.ts
similarity index 58%
copy from packages/aml-backoffice-ui/src/hooks/account.ts
copy to packages/aml-backoffice-ui/src/hooks/server-info.ts
index a4fbcb803..47743440a 100644
--- a/packages/aml-backoffice-ui/src/hooks/account.ts
+++ b/packages/aml-backoffice-ui/src/hooks/server-info.ts
@@ -14,8 +14,15 @@
  GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/>
  */
 import {
+  AbsoluteTime,
+  AmlSpaDialect,
+  assertUnreachable,
+  EventCounter,
   OfficerAccount,
+  OperationOk,
+  opFixedSuccess,
   PaytoString,
+  TalerCorebankApi,
   TalerExchangeResultByMethod,
   TalerHttpError,
 } from "@gnu-taler/taler-util";
@@ -23,36 +30,31 @@ import {
 import { useExchangeApiContext } from "@gnu-taler/web-util/browser";
 import _useSWR, { mutate, SWRHook } from "swr";
 import { useOfficer } from "./officer.js";
+import { AmlProgram } from "../../../taler-util/lib/types-taler-kyc-aml.js";
+import {
+  AML_EVENTS_INFO,
+  AmlEventsName,
+} from "../pages/decision/aml-events.js";
+import { useMemo } from "preact/hooks";
+import { Timeframe } from "../pages/Dashboard.js";
 const useSWR = _useSWR as unknown as SWRHook;
 
-export function revalidateAccountInformation() {
-  return mutate(
-    (key) =>
-      Array.isArray(key) &&
-      key[key.length - 1] === "getAmlAttributesForAccount",
-    undefined,
-    { revalidate: true },
-  );
-}
-export function useAccountInformation(paytoHash?: string) {
+export function useServerMeasures() {
   const officer = useOfficer();
-  const session =
-    officer.state === "ready" && paytoHash !== undefined
-      ? officer.account
-      : undefined;
+  const session = officer.state === "ready" ? officer.account : undefined;
 
   const {
     lib: { exchange: api },
   } = useExchangeApiContext();
 
-  async function fetcher([officer, account]: [OfficerAccount, PaytoString]) {
-    return await api.getAmlAttributesForAccount(officer, account);
+  async function fetcher([officer]: [OfficerAccount]) {
+    return await api.getAmlMesasures(officer);
   }
 
   const { data, error } = useSWR<
-    TalerExchangeResultByMethod<"getAmlAttributesForAccount">,
+    TalerExchangeResultByMethod<"getAmlMesasures">,
     TalerHttpError
-  >(!session ? undefined : [session, paytoHash], fetcher, {
+  >(!session ? undefined : [session], fetcher, {
     refreshInterval: 0,
     refreshWhenHidden: false,
     revalidateOnFocus: false,
@@ -69,7 +71,15 @@ export function useAccountInformation(paytoHash?: string) {
   return undefined;
 }
 
-export function useServerMeasures() {
+export type ServerStats = {
+  [name: string]: EventCounter;
+};
+
+export function useServerStatistics(
+  dialect: AmlSpaDialect,
+  current: Timeframe,
+  previous?: Timeframe,
+) {
   const officer = useOfficer();
   const session = officer.state === "ready" ? officer.account : undefined;
 
@@ -77,14 +87,48 @@ export function useServerMeasures() {
     lib: { exchange: api },
   } = useExchangeApiContext();
 
-  async function fetcher([officer]: [OfficerAccount]) {
-    return await api.getAmlMesasures(officer);
+  const keys = useMemo(() => {
+    return Object.entries(AML_EVENTS_INFO)
+      .filter(([name, info]) => {
+        return info.dialect.includes(dialect);
+      })
+      .map(([name]) => name as AmlEventsName);
+  }, [dialect]);
+
+  async function fetcher([officer, keys, current, previous]: [
+    OfficerAccount,
+    AmlEventsName[],
+    Timeframe,
+    Timeframe | undefined,
+  ]) {
+    const queries = keys.map((key) => {
+      return Promise.all([
+        api.getAmlKycStatistics(officer, key, {
+          since: current.start,
+          until: current.end,
+        }),
+        !previous
+          ? undefined
+          : api.getAmlKycStatistics(officer, key, {
+              since: previous.start,
+              until: previous.end,
+            }),
+      ]).then(([c, p]) => {
+        return { key, current: c.body, previous: p?.body };
+      });
+    });
+
+    const p = await Promise.all(queries);
+
+    return opFixedSuccess(p);
   }
 
   const { data, error } = useSWR<
-    TalerExchangeResultByMethod<"getAmlMesasures">,
+    OperationOk<
+      { key: AmlEventsName; current: EventCounter; previous?: EventCounter }[]
+    >,
     TalerHttpError
-  >(!session ? undefined : [session], fetcher, {
+  >(!session ? undefined : [session, keys, current, previous], fetcher, {
     refreshInterval: 0,
     refreshWhenHidden: false,
     revalidateOnFocus: false,
diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx 
b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
index dd5aea232..68c037cc7 100644
--- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
+++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
@@ -58,13 +58,14 @@ import { useState } from "preact/hooks";
 import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
 import { useUiFormsContext } from "../context/ui-forms.js";
 import { preloadedForms } from "../forms/index.js";
-import { useAccountInformation, useServerMeasures } from "../hooks/account.js";
+import { useAccountInformation } from "../hooks/account.js";
 import { DecisionRequest } from "../hooks/decision-request.js";
 import { useAccountDecisions } from "../hooks/decisions.js";
 import { useOfficer } from "../hooks/officer.js";
 import { CurrentMeasureTable, MeasureInfo } from "./MeasuresTable.js";
 import { Officer } from "./Officer.js";
 import { ShowConsolidated } from "./ShowConsolidated.js";
+import { useServerMeasures } from "../hooks/server-info.js";
 
 export type AmlEvent =
   | AmlFormEvent
@@ -142,13 +143,13 @@ export function CaseDetails({
   // paytoString?: PaytoString;
 }) {
   const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now());
-  const [request, setDesicionRequest] = useState<NewDecision | undefined>(
-    undefined,
-  );
+  // const [request, setDesicionRequest] = useState<NewDecision | undefined>(
+  //   undefined,
+  // );
   // const [decisionWizardStep, setDecisionWizardStep] =
   //   useState<WizardSteps>("events");
-  const [selectMeasure, setSelectMeasure] = useState<boolean>();
-  const { config } = useExchangeApiContext();
+  // const [selectMeasure, setSelectMeasure] = useState<boolean>();
+  // const { config } = useExchangeApiContext();
 
   const { i18n } = useTranslationContext();
   const details = useAccountInformation(account);
@@ -196,47 +197,47 @@ export function CaseDetails({
 
   const events = getEventsFromAmlHistory(accountDetails, i18n, allForms);
 
-  if (selectMeasure) {
-    return (
-      <ShowMeasuresToSelect
-        onSelect={(d) => {
-          setSelectMeasure(false);
-          setDesicionRequest({
-            request: {
-              // payto_uri: paytoString,
-              decision_time: AbsoluteTime.toProtocolTimestamp(
-                AbsoluteTime.now(),
-              ),
-              h_payto: account,
-              keep_investigating: false,
-              properties: {},
-              // the custom measure with context
-              new_measures: d.name,
-              new_rules: {
-                // this value is going to be overridden
-                custom_measures: {},
-                expiration_time: AbsoluteTime.toProtocolTimestamp(
-                  AbsoluteTime.never(),
-                ),
-                rules: FREEZE_RULES(config.config.currency),
-              },
-            },
-            askInformation: false,
-          });
-        }}
-      />
-    );
-  }
-  if (request) {
-    return (
-      <SubmitNewDecision
-        decision={request}
-        onComplete={() => {
-          setDesicionRequest(undefined);
-        }}
-      />
-    );
-  }
+  // if (selectMeasure) {
+  //   return (
+  //     <ShowMeasuresToSelect
+  //       onSelect={(d) => {
+  //         setSelectMeasure(false);
+  //         setDesicionRequest({
+  //           request: {
+  //             // payto_uri: paytoString,
+  //             decision_time: AbsoluteTime.toProtocolTimestamp(
+  //               AbsoluteTime.now(),
+  //             ),
+  //             h_payto: account,
+  //             keep_investigating: false,
+  //             properties: {},
+  //             // the custom measure with context
+  //             new_measures: d.name,
+  //             new_rules: {
+  //               // this value is going to be overridden
+  //               custom_measures: {},
+  //               expiration_time: AbsoluteTime.toProtocolTimestamp(
+  //                 AbsoluteTime.never(),
+  //               ),
+  //               rules: FREEZE_RULES(config.config.currency),
+  //             },
+  //           },
+  //           askInformation: false,
+  //         });
+  //       }}
+  //     />
+  //   );
+  // }
+  // if (request) {
+  //   return (
+  //     <SubmitNewDecision
+  //       decision={request}
+  //       onComplete={() => {
+  //         setDesicionRequest(undefined);
+  //       }}
+  //     />
+  //   );
+  // }
   // if (decisionWizardStep) {
   //   return (
   // <AmlDecisionRequestWizard
@@ -254,6 +255,7 @@ export function CaseDetails({
             onNewDecision({
               deadline: undefined,
               custom_properties: undefined,
+              onExpire_measures: undefined,
               custom_events: undefined,
               inhibit_events: undefined,
               justification: undefined,
@@ -267,7 +269,7 @@ export function CaseDetails({
         >
           <i18n.Translate>New decision</i18n.Translate>
         </button>
-
+        {/* 
         <button
           onClick={async () => {
             setDesicionRequest({
@@ -377,16 +379,16 @@ export function CaseDetails({
           class="m-4  rounded-md w-fit border-0 px-3 py-2 text-center text-sm 
bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
         >
           <i18n.Translate>Ask for more information</i18n.Translate>
-        </button>
+        </button> */}
 
-        <button
+        {/* <button
           onClick={async () => {
             setSelectMeasure(true);
           }}
           class="m-4  rounded-md w-fit border-0 px-3 py-2 text-center text-sm 
bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
         >
           <i18n.Translate>Set predefined measure</i18n.Translate>
-        </button>
+        </button> */}
       </div>
     );
   }
diff --git a/packages/aml-backoffice-ui/src/pages/Dashboard.tsx 
b/packages/aml-backoffice-ui/src/pages/Dashboard.tsx
new file mode 100644
index 000000000..e87236cfc
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/Dashboard.tsx
@@ -0,0 +1,570 @@
+/*
+ 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,
+  AmlSpaDialect,
+  assertUnreachable,
+  TalerCorebankApi,
+  TalerError,
+  TranslatedString,
+} from "@gnu-taler/taler-util";
+import {
+  InternationalizationAPI,
+  RouteDefinition,
+  useExchangeApiContext,
+  useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { format, sub } from "date-fns";
+import { Fragment, h, VNode } from "preact";
+import { useMemo, useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { useOfficer } from "../hooks/officer.js";
+import { usePreferences } from "../hooks/preferences.js";
+import { useServerStatistics } from "../hooks/server-info.js";
+import { AmlEventsName } from "./decision/aml-events.js";
+import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
+
+export function Dashboard({
+  routeDownloadStats,
+}: {
+  routeDownloadStats: RouteDefinition;
+}) {
+  const officer = useOfficer();
+  const { i18n } = useTranslationContext();
+
+  if (officer.state !== "ready") {
+    return <HandleAccountNotReady officer={officer} />;
+  }
+
+  return (
+    <div>
+      <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 ">
+        <i18n.Translate>Dashboard</i18n.Translate>
+      </h1>
+      <EventMetrics routeDownloadStats={routeDownloadStats} />
+    </div>
+  );
+}
+
+const now = new Date();
+
+function EventMetrics({
+  routeDownloadStats,
+}: {
+  routeDownloadStats: RouteDefinition;
+}): VNode {
+  const { i18n, dateLocale } = useTranslationContext();
+  const [metricType, setMetricType] =
+    useState<TalerCorebankApi.MonitorTimeframeParam>(
+      TalerCorebankApi.MonitorTimeframeParam.hour,
+    );
+  const params = useMemo(
+    () => getTimeframesForDate(now, metricType),
+    [metricType],
+  );
+
+  const [pref] = usePreferences();
+  const { config } = useExchangeApiContext();
+
+  const dialect =
+    (pref.testingDialect ? undefined : config.config.aml_spa_dialect) ??
+    AmlSpaDialect.TESTING;
+
+  const resp = useServerStatistics(dialect, params.current, params.previous);
+
+  if (!resp) return <Fragment />;
+  if (resp instanceof TalerError) {
+    return <ErrorLoadingWithDebug error={resp} />;
+  }
+
+  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>Events</i18n.Translate>
+          </h1>
+        </div>
+      </div>
+
+      <SelectTimeframe timeframe={metricType} setTimeframe={setMetricType} />
+
+      <div class="w-full flex justify-between">
+        <h1 class="text-base text-gray-900 mt-5">
+          {i18n.str`Trading volume from ${getDateStringForTimeframe(
+            params.current.start,
+            metricType,
+            dateLocale,
+          )} to ${getDateStringForTimeframe(
+            params.current.end,
+            metricType,
+            dateLocale,
+          )}`}
+        </h1>
+      </div>
+
+      <dl class="mt-5 grid grid-cols-1 md:grid-cols-4  divide-y 
divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x 
md:divide-y-0">
+        {resp.body.map((ev) => {
+          const label = labelForEvent(ev.key, i18n);
+          const desc = descriptionForEvent(ev.key, i18n);
+          return (
+            <div class="px-4 py-5 sm:p-6">
+              <dt class="text-base font-normal text-gray-900">
+                {label}
+                {!desc ? undefined : (
+                  <div class="text-xs text-gray-500">{desc}</div>
+                )}
+              </dt>
+              <MetricValueNumber
+                current={ev.current.counter}
+                previous={ev.previous?.counter}
+              />
+            </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 labelForEvent(name: AmlEventsName, i18n: InternationalizationAPI) {
+  switch (name) {
+    case AmlEventsName.ACCOUNT_FROZEN:
+      return i18n.str`Frozen accounts`;
+    case AmlEventsName.ACCOUNT_PEP:
+      return i18n.str`PEP persons`;
+    case AmlEventsName.TEST_EVENT_KEY_1:
+      return i18n.str`TEST_EVENT_KEY_1`;
+    case AmlEventsName.TEST_EVENT_KEY_2:
+      return i18n.str`TEST_EVENT_KEY_2`;
+    case AmlEventsName.TEST_EVENT_KEY_3:
+      return i18n.str`TEST_EVENT_KEY_3`;
+    case AmlEventsName.TEST_EVENT_KEY_4:
+      return i18n.str`TEST_EVENT_KEY_4`;
+    case AmlEventsName.TEST_EVENT_KEY_5:
+      return i18n.str`TEST_EVENT_KEY_5`;
+    case AmlEventsName.TEST_EVENT_KEY_6:
+      return i18n.str`TEST_EVENT_KEY_6`;
+    case AmlEventsName.TEST_EVENT_KEY_7:
+      return i18n.str`TEST_EVENT_KEY_7`;
+    case AmlEventsName.TEST_EVENT_KEY_8:
+      return i18n.str`TEST_EVENT_KEY_8`;
+    case AmlEventsName.TEST_EVENT_KEY_9:
+      return i18n.str`TEST_EVENT_KEY_9`;
+    case AmlEventsName.TEST_EVENT_KEY_10:
+      return i18n.str`TEST_EVENT_KEY_10`;
+    case AmlEventsName.TEST_EVENT_KEY_11:
+      return i18n.str`TEST_EVENT_KEY_11`;
+    case AmlEventsName.TEST_EVENT_KEY_12:
+      return i18n.str`TEST_EVENT_KEY_12`;
+    case AmlEventsName.TEST_EVENT_KEY_13:
+      return i18n.str`TEST_EVENT_KEY_13`;
+    case AmlEventsName.TEST_EVENT_KEY_14:
+      return i18n.str`TEST_EVENT_KEY_14`;
+    default: {
+      assertUnreachable(name);
+    }
+  }
+}
+
+function descriptionForEvent(
+  name: AmlEventsName,
+  i18n: InternationalizationAPI,
+): TranslatedString | undefined {
+  switch (name) {
+    case AmlEventsName.ACCOUNT_FROZEN:
+      return i18n.str`Accounts that can move funds`;
+    case AmlEventsName.ACCOUNT_PEP:
+      return i18n.str`Public exposed persons`;
+    case AmlEventsName.TEST_EVENT_KEY_1:
+      return i18n.str`TEST_EVENT_KEY_1`;
+    case AmlEventsName.TEST_EVENT_KEY_2:
+      return i18n.str`TEST_EVENT_KEY_2`;
+    case AmlEventsName.TEST_EVENT_KEY_3:
+      return i18n.str`TEST_EVENT_KEY_3`;
+    case AmlEventsName.TEST_EVENT_KEY_4:
+      return i18n.str`TEST_EVENT_KEY_4`;
+    case AmlEventsName.TEST_EVENT_KEY_5:
+      return i18n.str`TEST_EVENT_KEY_5`;
+    case AmlEventsName.TEST_EVENT_KEY_6:
+      return i18n.str`TEST_EVENT_KEY_6`;
+    case AmlEventsName.TEST_EVENT_KEY_7:
+      return i18n.str`TEST_EVENT_KEY_7`;
+    case AmlEventsName.TEST_EVENT_KEY_8:
+      return i18n.str`TEST_EVENT_KEY_8`;
+    case AmlEventsName.TEST_EVENT_KEY_9:
+      return i18n.str`TEST_EVENT_KEY_9`;
+    case AmlEventsName.TEST_EVENT_KEY_10:
+      return i18n.str`TEST_EVENT_KEY_10`;
+    case AmlEventsName.TEST_EVENT_KEY_11:
+      return i18n.str`TEST_EVENT_KEY_11`;
+    case AmlEventsName.TEST_EVENT_KEY_12:
+      return i18n.str`TEST_EVENT_KEY_12`;
+    case AmlEventsName.TEST_EVENT_KEY_13:
+      return i18n.str`TEST_EVENT_KEY_13`;
+    case AmlEventsName.TEST_EVENT_KEY_14:
+      return i18n.str`TEST_EVENT_KEY_14`;
+    default: {
+      assertUnreachable(name);
+    }
+  }
+}
+
+function MetricValueNumber({
+  current,
+  previous,
+}: {
+  current: number | undefined;
+  previous: number | undefined;
+}): VNode {
+  const { i18n } = useTranslationContext();
+
+  const cmp = current && previous ? (current < previous ? -1 : 1) : 0;
+
+  const rate =
+    !current || Number.isNaN(current) || !previous || Number.isNaN(previous)
+      ? 0
+      : cmp === -1
+        ? 1 - Math.round(current) / Math.round(previous)
+        : cmp === 1
+          ? Math.round(current) / Math.round(previous) - 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 ? "-" : current}
+        </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>previous</i18n.Translate>{" "}
+              {!previous ? "-" : previous}
+            </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>
+  );
+}
+
+export type Timeframe = { start: AbsoluteTime; end: AbsoluteTime };
+
+export function getTimeframesForDate(
+  time: Date,
+  timeframe: TalerCorebankApi.MonitorTimeframeParam,
+): {
+  current: Timeframe;
+  previous: Timeframe;
+} {
+  switch (timeframe) {
+    case TalerCorebankApi.MonitorTimeframeParam.hour: {
+      const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+        AbsoluteTime.fromMilliseconds(
+          sub(time, { hours: timeIndex }).getTime(),
+        ),
+      );
+      return {
+        current: { start: middle, end: high },
+        previous: { start: low, end: middle },
+      };
+    }
+    case TalerCorebankApi.MonitorTimeframeParam.day: {
+      const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+        AbsoluteTime.fromMilliseconds(sub(time, { days: timeIndex 
}).getTime()),
+      );
+      return {
+        current: { start: middle, end: high },
+        previous: { start: low, end: middle },
+      };
+    }
+    case TalerCorebankApi.MonitorTimeframeParam.month: {
+      const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+        AbsoluteTime.fromMilliseconds(
+          sub(time, { months: timeIndex }).getTime(),
+        ),
+      );
+      return {
+        current: { start: middle, end: high },
+        previous: { start: low, end: middle },
+      };
+    }
+
+    case TalerCorebankApi.MonitorTimeframeParam.year: {
+      const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+        AbsoluteTime.fromMilliseconds(
+          sub(time, { years: timeIndex }).getTime(),
+        ),
+      );
+      return {
+        current: { start: middle, end: high },
+        previous: { start: low, end: middle },
+      };
+    }
+    case TalerCorebankApi.MonitorTimeframeParam.decade: {
+      const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+        AbsoluteTime.fromMilliseconds(
+          sub(time, { years: timeIndex * 10 }).getTime(),
+        ),
+      );
+      return {
+        current: { start: middle, end: high },
+        previous: { start: low, end: middle },
+      };
+    }
+    default:
+      assertUnreachable(timeframe);
+  }
+}
+
+function getDateStringForTimeframe(
+  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:00", { 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);
+}
+
+function SelectTimeframe({
+  timeframe,
+  setTimeframe,
+}: {
+  timeframe: TalerCorebankApi.MonitorTimeframeParam;
+  setTimeframe: (t: TalerCorebankApi.MonitorTimeframeParam) => void;
+}): VNode {
+  const { i18n } = useTranslationContext();
+  return (
+    <Fragment>
+      <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) => {
+            setTimeframe(
+              parseInt(
+                e.currentTarget.value,
+                10,
+              ) as TalerCorebankApi.MonitorTimeframeParam,
+            );
+          }}
+        >
+          <option
+            value={TalerCorebankApi.MonitorTimeframeParam.hour}
+            selected={timeframe == TalerCorebankApi.MonitorTimeframeParam.hour}
+          >
+            <i18n.Translate>Last hour</i18n.Translate>
+          </option>
+          <option
+            value={TalerCorebankApi.MonitorTimeframeParam.day}
+            selected={timeframe == TalerCorebankApi.MonitorTimeframeParam.day}
+          >
+            <i18n.Translate>Previous day</i18n.Translate>
+          </option>
+          <option
+            value={TalerCorebankApi.MonitorTimeframeParam.month}
+            selected={timeframe == 
TalerCorebankApi.MonitorTimeframeParam.month}
+          >
+            <i18n.Translate>Last month</i18n.Translate>
+          </option>
+          <option
+            value={TalerCorebankApi.MonitorTimeframeParam.year}
+            selected={timeframe == 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();
+              setTimeframe(TalerCorebankApi.MonitorTimeframeParam.hour);
+            }}
+            data-selected={
+              timeframe == 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={
+                timeframe == 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();
+              setTimeframe(TalerCorebankApi.MonitorTimeframeParam.day);
+            }}
+            data-selected={
+              timeframe == 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={
+                timeframe == 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();
+              setTimeframe(TalerCorebankApi.MonitorTimeframeParam.month);
+            }}
+            data-selected={
+              timeframe == 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={
+                timeframe == 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();
+              setTimeframe(TalerCorebankApi.MonitorTimeframeParam.year);
+            }}
+            data-selected={
+              timeframe == 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={
+                timeframe == 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>
+    </Fragment>
+  );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/Measures.tsx 
b/packages/aml-backoffice-ui/src/pages/Measures.tsx
index 28236fe0c..571b77c8f 100644
--- a/packages/aml-backoffice-ui/src/pages/Measures.tsx
+++ b/packages/aml-backoffice-ui/src/pages/Measures.tsx
@@ -28,16 +28,13 @@ import {
 } from "@gnu-taler/web-util/browser";
 import { Fragment, h, VNode } from "preact";
 import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
-import { useServerMeasures } from "../hooks/account.js";
 import { CurrentMeasureTable, MeasureInfo } from "./MeasuresTable.js";
 import { Officer } from "./Officer.js";
+import { useServerMeasures } from "../hooks/server-info.js";
 
 export function Measures({}: {}) {
   const { i18n } = useTranslationContext();
 
-  // const { forms } = useUiFormsContext();
-
-  // const allForms = [...forms, ...preloadedForms(i18n)];
   const measures = useServerMeasures();
 
   if (!measures) {
diff --git 
a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx
index 3d6f8a1b4..b677e2a0d 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx
@@ -34,13 +34,15 @@ import { Properties } from "./Properties.js";
 import { Rules } from "./Rules.js";
 import { Measures } from "./Measures.js";
 import { Justification } from "./Justification.js";
+import { Summary } from "./Summary.js";
 
 export type WizardSteps =
   | "rules" // define the limits
   | "measures" // define a new form/challenge
   | "properties" // define account information
   | "events" // define events to trigger
-  | "justification"; // finalize, investigate?;
+  | "justification" // finalize, investigate?;
+  | "summary";
 
 const STEPS_ORDER: WizardSteps[] = [
   "rules",
@@ -48,6 +50,7 @@ const STEPS_ORDER: WizardSteps[] = [
   "properties",
   "events",
   "justification",
+  "summary",
 ];
 
 const STEPS_ORDER_MAP = STEPS_ORDER.reduce(
@@ -108,6 +111,8 @@ export function AmlDecisionRequestWizard({
         return <Measures />;
       case "justification":
         return <Justification />;
+      case "summary":
+        return <Summary />;
     }
     assertUnreachable(stepOrDefault);
   })();
@@ -178,6 +183,11 @@ function WizardSteps({
       description: i18n.str`Add information about the account.`,
       isCompleted: isPropertiesCompleted,
     },
+    summary: {
+      label: i18n.str`Summary`,
+      description: i18n.str`Review and send.`,
+      isCompleted: () => false,
+    },
   };
   return (
     <div class="lg:border-b lg:border-t lg:border-gray-200">
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx
index e65586a77..a984b430a 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx
@@ -1,19 +1,23 @@
 import {
-  useTranslationContext,
-  useExchangeApiContext,
-  useForm,
+  AmlSpaDialect,
+  assertUnreachable,
+  MeasureInformation,
+} from "@gnu-taler/taler-util";
+import {
+  FormDesign,
   FormUI,
-  TalerFormAttributes,
-  UIHandlerId,
   InternationalizationAPI,
-  UIFormElementConfig,
-  FormDesign,
   onComponentUnload,
+  UIFormElementConfig,
+  UIHandlerId,
+  useExchangeApiContext,
+  useForm,
+  useTranslationContext,
 } from "@gnu-taler/web-util/browser";
 import { h, VNode } from "preact";
 import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
 import { usePreferences } from "../../hooks/preferences.js";
-import { MeasureInformation } from "@gnu-taler/taler-util";
+import { AML_EVENTS_INFO, AmlEventsName } from "./aml-events.js";
 
 /**
  * Trigger additional events
@@ -26,21 +30,36 @@ export function Events({}: {}): VNode {
   const [pref] = usePreferences();
   const { config } = useExchangeApiContext();
 
-  const calculatedProps = {
-    ...(request.properties ?? {}),
-    ...(request.custom_properties ?? {}),
-  };
+  const dialect =
+    (pref.testingDialect ? undefined : config.config.aml_spa_dialect) ??
+    AmlSpaDialect.TESTING;
 
-  const calculatedEvents = eventsByDialect(
-    i18n,
-    pref.testingDialect ? "testing" : config.config.aml_spa_dialect,
-    calculatedProps,
+  const calculatedEvents = Object.entries(AML_EVENTS_INFO).reduce(
+    (prev, [name, info]) => {
+      const field = {
+        id: name as UIHandlerId,
+        type: "toggle",
+        required: true,
+        label: labelForEvent(name as AmlEventsName, i18n),
+      } satisfies UIFormElementConfig;
+
+      if (info.shouldBeTriggered(request, dialect)) {
+        prev.on.push(field);
+      } else {
+        prev.off.push(field);
+      }
+      return prev;
+    },
+    { on: [], off: [] } as {
+      on: UIFormElementConfig[];
+      off: UIFormElementConfig[];
+    },
   );
 
-  const design = eventsForm(i18n, calculatedEvents);
+  const design = formDesign(i18n, calculatedEvents.on);
 
-  const form = useForm<EventsForm>(design, {
-    inhibit: calculatedEvents.reduce(
+  const form = useForm<FormType>(design, {
+    inhibit: calculatedEvents.on.reduce(
       (prev, cur) => {
         if (cur.type !== "toggle") return prev;
         const isInhibit =
@@ -49,7 +68,7 @@ export function Events({}: {}): VNode {
         prev[cur.id] = isInhibit;
         return prev;
       },
-      {} as EventsForm["inhibit"],
+      {} as FormType["inhibit"],
     ),
     trigger: !request.custom_events
       ? []
@@ -75,28 +94,28 @@ export function Events({}: {}): VNode {
   );
 }
 
-export type EventsForm = {
+type FormType = {
   trigger: { name: string }[];
   inhibit: { [name: string]: boolean };
 };
 
-export const eventsForm = (
+const formDesign = (
   i18n: InternationalizationAPI,
-  defaultEvents: UIFormElementConfig[],
+  inhibitEvents: UIFormElementConfig[],
 ): FormDesign<MeasureInformation> => ({
   type: "double-column",
   sections: [
     {
       title: i18n.str`Inhibit default events`,
-      description: i18n.str`Use this form to prevent events to be triggered by 
the current status.`,
-      fields: !defaultEvents.length
+      description: i18n.str`Here you can prevent events to be triggered by the 
current status.`,
+      fields: !inhibitEvents.length
         ? [
             {
               type: "caption",
               label: i18n.str`No default events calculated.`,
             },
           ]
-        : defaultEvents.map((f) =>
+        : inhibitEvents.map((f) =>
             "id" in f ? { ...f, id: ("inhibit." + f.id) as UIHandlerId } : f,
           ),
     },
@@ -120,89 +139,44 @@ export const eventsForm = (
       ],
     },
   ],
-  // fields: [
-  //   {
-  //     id: "trigger" as UIHandlerId,
-  //     type: "array",
-  //     labelFieldId: "name" as UIHandlerId,
-  //     label: i18n.str`Trigger`,
-  //     fields: [],
-  //   },
-  //   {
-  //     id: "inhibit" as UIHandlerId,
-  //     type: "array",
-  //     labelFieldId: "name" as UIHandlerId,
-  //     label: i18n.str`Inhibit`,
-  //     fields: [],
-  //   },
-  // ],
 });
 
-export function eventsByDialect(
-  i18n: InternationalizationAPI,
-  dialect: string | undefined,
-  properties: object,
-): UIFormElementConfig[] {
-  if (!dialect) return [];
-  const result: UIFormElementConfig[] = [];
-  switch (dialect) {
-    case "testing": {
-      const props = properties as TalerFormAttributes.AccountProperties_TOPS;
-      if (props.ACCOUNT_FROZEN) {
-        result.push({
-          id: "ACCOUNT_FROZEN" satisfies keyof 
TalerFormAttributes.AccountProperties_TOPS as UIHandlerId,
-          label: i18n.str`Is froozen?`,
-          // gana_type: "Boolean",
-          type: "toggle",
-          required: true,
-        });
-      }
-      if (props.ACCOUNT_SANCTIONED) {
-        result.push({
-          id: "ACCOUNT_SANCTIONED" satisfies keyof 
TalerFormAttributes.AccountProperties_TOPS as UIHandlerId,
-          label: i18n.str`Is sacntioned?`,
-          // gana_type: "Boolean",
-          type: "toggle",
-          required: true,
-        });
-      }
-      if (props.ACCOUNT_HIGH_RISK) {
-        result.push({
-          id: "ACCOUNT_HIGH_RISK" satisfies keyof 
TalerFormAttributes.AccountProperties_TOPS as UIHandlerId,
-          label: i18n.str`Is high risk?`,
-          // gana_type: "Boolean",
-          type: "toggle",
-          required: true,
-        });
-      }
-      break;
-    }
-    case "gls": {
-      const props = properties as TalerFormAttributes.AccountProperties_TOPS;
-      if (props.ACCOUNT_FROZEN) {
-        result.push({
-          id: "ACCOUNT_FROZEN" satisfies keyof 
TalerFormAttributes.AccountProperties_TOPS as UIHandlerId,
-          label: i18n.str`Is frozen?`,
-          // gana_type: "Boolean",
-          type: "toggle",
-          required: true,
-        });
-      }
-      break;
-    }
-    case "tops": {
-      const props = properties as TalerFormAttributes.AccountProperties_TOPS;
-      if (props.ACCOUNT_HIGH_RISK) {
-        result.push({
-          id: "ACCOUNT_HIGH_RISK" satisfies keyof 
TalerFormAttributes.AccountProperties_TOPS as UIHandlerId,
-          label: i18n.str`Is high risk?`,
-          // gana_type: "Boolean",
-          type: "toggle",
-          required: true,
-        });
-      }
-      break;
+function labelForEvent(event: AmlEventsName, i18n: InternationalizationAPI) {
+  switch (event) {
+    case AmlEventsName.ACCOUNT_FROZEN:
+      return i18n.str`new account frozen`;
+    case AmlEventsName.ACCOUNT_PEP:
+      return i18n.str`new exposed person`;
+    case AmlEventsName.TEST_EVENT_KEY_1:
+      return i18n.str`TEST_EVENT_KEY_1`;
+    case AmlEventsName.TEST_EVENT_KEY_2:
+      return i18n.str`TEST_EVENT_KEY_2`;
+    case AmlEventsName.TEST_EVENT_KEY_3:
+      return i18n.str`TEST_EVENT_KEY_3`;
+    case AmlEventsName.TEST_EVENT_KEY_4:
+      return i18n.str`TEST_EVENT_KEY_4`;
+    case AmlEventsName.TEST_EVENT_KEY_5:
+      return i18n.str`TEST_EVENT_KEY_5`;
+    case AmlEventsName.TEST_EVENT_KEY_6:
+      return i18n.str`TEST_EVENT_KEY_6`;
+    case AmlEventsName.TEST_EVENT_KEY_7:
+      return i18n.str`TEST_EVENT_KEY_7`;
+    case AmlEventsName.TEST_EVENT_KEY_8:
+      return i18n.str`TEST_EVENT_KEY_8`;
+    case AmlEventsName.TEST_EVENT_KEY_9:
+      return i18n.str`TEST_EVENT_KEY_9`;
+    case AmlEventsName.TEST_EVENT_KEY_10:
+      return i18n.str`TEST_EVENT_KEY_10`;
+    case AmlEventsName.TEST_EVENT_KEY_11:
+      return i18n.str`TEST_EVENT_KEY_11`;
+    case AmlEventsName.TEST_EVENT_KEY_12:
+      return i18n.str`TEST_EVENT_KEY_12`;
+    case AmlEventsName.TEST_EVENT_KEY_13:
+      return i18n.str`TEST_EVENT_KEY_13`;
+    case AmlEventsName.TEST_EVENT_KEY_14:
+      return i18n.str`TEST_EVENT_KEY_14`;
+    default: {
+      assertUnreachable(event);
     }
   }
-  return result;
 }
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx
index 32c7110b2..a059d755d 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx
@@ -1,6 +1,27 @@
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+  FormDesign,
+  FormUI,
+  InternationalizationAPI,
+  onComponentUnload,
+  UIHandlerId,
+  useForm,
+  useTranslationContext,
+} from "@gnu-taler/web-util/browser";
 import { h, VNode } from "preact";
 import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
+import {
+  AbsoluteTime,
+  Duration,
+  MeasureInformation,
+  TalerError,
+} from "@gnu-taler/taler-util";
+import {
+  deserializeMeasures,
+  measureArrayField,
+  MeasurePath,
+  serializeMeasures,
+} from "./Measures.js";
+import { useServerMeasures } from "../../hooks/server-info.js";
 
 /**
  * Mark for further investigation and explain decision
@@ -9,6 +30,98 @@ import { useCurrentDecisionRequest } from 
"../../hooks/decision-request.js";
  */
 export function Justification({}: {}): VNode {
   const { i18n } = useTranslationContext();
-  const [request] = useCurrentDecisionRequest();
-  return <div> not yet impltemented: justification and investigation</div>;
+  const [request, _, updateRequest] = useCurrentDecisionRequest();
+  const measures = useServerMeasures();
+  const measureList =
+    !measures || measures instanceof TalerError || measures.type === "fail"
+      ? []
+      : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi }));
+  const design = formDesign(i18n, measureList);
+
+  const expMeasres: MeasurePath[] = !request.onExpire_measures
+    ? []
+    : deserializeMeasures(request.onExpire_measures);
+
+  const form = useForm<FormType>(design, {
+    investigate: request.keep_investigating,
+    justification: request.justification,
+    expiration: request.deadline,
+    measures: expMeasres,
+  });
+
+  onComponentUnload(() => {
+    updateRequest({
+      ...request,
+      keep_investigating: !!form.status.result.investigate,
+      justification: form.status.result.justification ?? "",
+      onExpire_measures: serializeMeasures(
+        (form.status.result.measures ?? []) as MeasurePath[],
+      ),
+
+      deadline:
+        (form.status.result.expiration as AbsoluteTime) ?? 
AbsoluteTime.never(),
+      // onExpire_measures,
+    });
+  });
+
+  return (
+    <div>
+      <FormUI design={design} handler={form.handler} />
+    </div>
+  );
 }
+
+type FormType = {
+  justification: string;
+  investigate: boolean;
+  expiration: AbsoluteTime;
+  measures: MeasurePath[];
+};
+
+const formDesign = (
+  i18n: InternationalizationAPI,
+  mi: (MeasureInformation & { id: string })[],
+): FormDesign<FormType> => ({
+  type: "single-column",
+  fields: [
+    {
+      id: "justification" as UIHandlerId,
+      type: "textArea",
+      label: i18n.str`Justification`,
+    },
+    {
+      id: "investigate" as UIHandlerId,
+      type: "toggle",
+      label: i18n.str`Keep investigation?`,
+    },
+    {
+      type: "choiceHorizontal",
+      label: i18n.str`Expiration`,
+      id: "expiration" as UIHandlerId,
+      choices: [
+        {
+          label: i18n.str`In a week`,
+          value: AbsoluteTime.addDuration(
+            AbsoluteTime.now(),
+            Duration.fromSpec({ days: 7 }),
+          ) as any,
+        },
+        {
+          label: i18n.str`In a month`,
+          value: AbsoluteTime.addDuration(
+            AbsoluteTime.now(),
+            Duration.fromSpec({ months: 1 }),
+          ) as any,
+        },
+      ],
+    },
+    {
+      id: "expiration" as UIHandlerId,
+      type: "absoluteTimeText",
+      placeholder: "dd/MM/yyyy",
+      pattern: "dd/MM/yyyy",
+      label: i18n.str`Expiration`,
+    },
+    measureArrayField(i18n, mi),
+  ],
+});
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx
index 1ef4d8859..0969e733b 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx
@@ -11,9 +11,9 @@ import {
   RecursivePartial,
 } from "@gnu-taler/web-util/browser";
 import { h, VNode } from "preact";
-import { useServerMeasures } from "../../hooks/account.js";
 import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
 import { ShowMeasuresToSelect } from "../CaseDetails.js";
+import { useServerMeasures } from "../../hooks/server-info.js";
 
 export function serializeMeasures(
   paths?: RecursivePartial<MeasurePath[]>,
@@ -47,12 +47,12 @@ export function Measures({}: {}): VNode {
       ? []
       : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi }));
 
-  const initValue: MeasureForm = !request.new_measures
+  const initValue: FormType = !request.new_measures
     ? { paths: [] }
     : { paths: deserializeMeasures(request.new_measures) };
 
-  const design = measureForm(i18n, measureList);
-  const form = useForm<MeasureForm>(design, initValue);
+  const design = formDesign(i18n, measureList);
+  const form = useForm<FormType>(design, initValue);
 
   onComponentUnload(() => {
     const r = !form.status.result.paths
@@ -77,9 +77,9 @@ export function Measures({}: {}): VNode {
   );
 }
 
-type MeasurePath = { steps: string[] };
+export type MeasurePath = { steps: string[] };
 
-type MeasureForm = {
+type FormType = {
   paths: MeasurePath[];
 };
 
@@ -110,10 +110,10 @@ export function measureArrayField(
   };
 }
 
-function measureForm(
+function formDesign(
   i18n: InternationalizationAPI,
   mi: (MeasureInformation & { id: string })[],
-): FormDesign<MeasureForm> {
+): FormDesign<FormType> {
   return {
     type: "single-column",
     fields: [measureArrayField(i18n, mi)],
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx
index ef685e857..d74b93cc8 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx
@@ -13,6 +13,7 @@ import {
 import { h, VNode } from "preact";
 import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
 import { usePreferences } from "../../hooks/preferences.js";
+import { AmlSpaDialect } from "@gnu-taler/taler-util";
 
 /**
  * Update account properites
@@ -115,7 +116,7 @@ export function propertiesByDialect(
 ): UIFormElementConfig[] {
   if (!dialect) return [];
   switch (dialect) {
-    case "testing": {
+    case AmlSpaDialect.TESTING: {
       return [
         {
           id: "ACCOUNT_PEP" satisfies keyof 
TalerFormAttributes.AccountProperties_Testing as UIHandlerId,
@@ -124,9 +125,23 @@ export function propertiesByDialect(
           type: "toggle",
           required: true,
         },
+        {
+          id: "ACCOUNT_BUSINESS_DOMAIN" satisfies keyof 
TalerFormAttributes.AccountProperties_Testing as UIHandlerId,
+          label: i18n.str`Business domain`,
+          // gana_type: "Text",
+          type: "text",
+          required: true,
+        },
+        {
+          id: "ACCOUNT_FROZEN" satisfies keyof 
TalerFormAttributes.AccountProperties_Testing as UIHandlerId,
+          label: i18n.str`Is frozen?`,
+          // gana_type: "Boolean",
+          type: "toggle",
+          required: true,
+        },
       ];
     }
-    case "gls": {
+    case AmlSpaDialect.GLS: {
       return [
         {
           id: "ACCOUNT_REPORTED" satisfies keyof 
TalerFormAttributes.AccountProperties_GLS as UIHandlerId,
@@ -137,7 +152,7 @@ export function propertiesByDialect(
         },
       ];
     }
-    case "tops": {
+    case AmlSpaDialect.TOPS: {
       return [
         {
           id: "ACCOUNT_FROZEN" satisfies keyof 
TalerFormAttributes.AccountProperties_TOPS as UIHandlerId,
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx
index db1653e02..9bf5657d7 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx
@@ -20,12 +20,11 @@ import {
   useTranslationContext,
 } from "@gnu-taler/web-util/browser";
 import { h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { useServerMeasures } from "../../hooks/account.js";
 import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
 import { useAccountActiveDecision } from "../../hooks/decisions.js";
 import { RulesInfo, ShowDecisionLimitInfo } from "../CaseDetails.js";
 import { measureArrayField, serializeMeasures } from "./Measures.js";
+import { useServerMeasures } from "../../hooks/server-info.js";
 
 /**
  * Defined new limits for the account
@@ -55,6 +54,12 @@ export function Rules({ account }: { account?: string }): 
VNode {
       ? undefined
       : activeDecision.body;
 
+  onComponentUnload(() => {
+    if (!request.rules) {
+      updateRequest("rules", []);
+    }
+  });
+
   function addNewRule(nr: FormType) {
     const result = !request.rules ? [] : [...request.rules];
     result.push({
@@ -132,13 +137,6 @@ type FormType = {
   paths: { steps: Array<string> }[];
 };
 
-// operation_type: LimitOperationType;
-// threshold: AmountString;
-// timeframe: RelativeTime;
-// measures: string[];
-// display_priority: Integer;
-// exposed?: boolean;
-// is_and_combinator?: boolean;
 function labelForOperationType(
   op: LimitOperationType,
   i18n: InternationalizationAPI,
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx
similarity index 55%
copy from packages/aml-backoffice-ui/src/pages/decision/Justification.tsx
copy to packages/aml-backoffice-ui/src/pages/decision/Summary.tsx
index 32c7110b2..37fa9b493 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx
@@ -1,4 +1,12 @@
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+  FormDesign,
+  FormUI,
+  InternationalizationAPI,
+  onComponentUnload,
+  UIHandlerId,
+  useForm,
+  useTranslationContext,
+} from "@gnu-taler/web-util/browser";
 import { h, VNode } from "preact";
 import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
 
@@ -7,8 +15,9 @@ import { useCurrentDecisionRequest } from 
"../../hooks/decision-request.js";
  * @param param0
  * @returns
  */
-export function Justification({}: {}): VNode {
+export function Summary({}: {}): VNode {
   const { i18n } = useTranslationContext();
   const [request] = useCurrentDecisionRequest();
-  return <div> not yet impltemented: justification and investigation</div>;
+
+  return <div>summary</div>;
 }
diff --git a/packages/aml-backoffice-ui/src/pages/decision/aml-events.ts 
b/packages/aml-backoffice-ui/src/pages/decision/aml-events.ts
new file mode 100644
index 000000000..535e68a82
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/decision/aml-events.ts
@@ -0,0 +1,133 @@
+import { AmlSpaDialect } from "@gnu-taler/taler-util";
+import { TalerFormAttributes } from "@gnu-taler/web-util/browser";
+import { DecisionRequest } from "../../hooks/decision-request.js";
+
+export enum AmlEventsName {
+  ACCOUNT_FROZEN = "ACCOUNT_FROZEN",
+  ACCOUNT_PEP = "ACCOUNT_PEP",
+  TEST_EVENT_KEY_1 = "TEST_EVENT_KEY_1",
+  TEST_EVENT_KEY_2 = "TEST_EVENT_KEY_2",
+  TEST_EVENT_KEY_3 = "TEST_EVENT_KEY_3",
+  TEST_EVENT_KEY_4 = "TEST_EVENT_KEY_4",
+  TEST_EVENT_KEY_5 = "TEST_EVENT_KEY_5",
+  TEST_EVENT_KEY_6 = "TEST_EVENT_KEY_6",
+  TEST_EVENT_KEY_7 = "TEST_EVENT_KEY_7",
+  TEST_EVENT_KEY_8 = "TEST_EVENT_KEY_8",
+  TEST_EVENT_KEY_9 = "TEST_EVENT_KEY_9",
+  TEST_EVENT_KEY_10 = "TEST_EVENT_KEY_10",
+  TEST_EVENT_KEY_11 = "TEST_EVENT_KEY_11",
+  TEST_EVENT_KEY_12 = "TEST_EVENT_KEY_12",
+  TEST_EVENT_KEY_13 = "TEST_EVENT_KEY_13",
+  TEST_EVENT_KEY_14 = "TEST_EVENT_KEY_14",
+}
+
+export type EventMapInfo = {
+  [name in AmlEventsName]: {
+    // fieldLabel: TranslatedString;
+    dialect: AmlSpaDialect[];
+    shouldBeTriggered: (req: DecisionRequest, d: AmlSpaDialect) => boolean;
+  };
+};
+
+export const AML_EVENTS_INFO: EventMapInfo = {
+  ACCOUNT_FROZEN: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      if (!req.properties) return false;
+      return !!(req.properties as TalerFormAttributes.AccountProperties_TOPS)
+        .ACCOUNT_FROZEN;
+    },
+  },
+  ACCOUNT_PEP: {
+    dialect: [AmlSpaDialect.GLS, AmlSpaDialect.TOPS, AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      if (!req.properties) return false;
+      return !!(req.properties as TalerFormAttributes.AccountProperties_TOPS)
+        .ACCOUNT_PEP;
+    },
+  },
+  TEST_EVENT_KEY_1: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_2: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_3: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_4: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_5: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_6: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_7: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_8: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_9: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_10: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_11: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_12: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_13: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+  TEST_EVENT_KEY_14: {
+    dialect: [AmlSpaDialect.TESTING],
+    shouldBeTriggered(req, dialect) {
+      return true;
+    },
+  },
+};
diff --git a/packages/bank-ui/src/hooks/regional.ts 
b/packages/bank-ui/src/hooks/regional.ts
index 417874cf8..d960a0f1f 100644
--- a/packages/bank-ui/src/hooks/regional.ts
+++ b/packages/bank-ui/src/hooks/regional.ts
@@ -42,10 +42,10 @@ const useSWR = _useSWR as unknown as SWRHook;
 
 export type TransferCalculation =
   | {
-    debit: AmountJson;
-    credit: AmountJson;
-    beforeFee: AmountJson;
-  }
+      debit: AmountJson;
+      credit: AmountJson;
+      beforeFee: AmountJson;
+    }
   | "amount-is-too-small";
 type EstimatorFunction = (
   amount: AmountJson,
@@ -112,7 +112,9 @@ export function useCashinEstimator(): ConversionEstimators {
             if (resp.detail) {
               throw TalerError.fromUncheckedDetail(resp.detail);
             } else {
-              throw TalerError.fromException(new Error("failed to get 
conversion cashin rate"))
+              throw TalerError.fromException(
+                new Error("failed to get conversion cashin rate"),
+              );
             }
         }
       }
@@ -141,7 +143,9 @@ export function useCashinEstimator(): ConversionEstimators {
             if (resp.detail) {
               throw TalerError.fromUncheckedDetail(resp.detail);
             } else {
-              throw TalerError.fromException(new Error("failed to get 
conversion cashin rate"))
+              throw TalerError.fromException(
+                new Error("failed to get conversion cashin rate"),
+              );
             }
         }
       }
@@ -178,7 +182,9 @@ export function useCashoutEstimator(): ConversionEstimators 
{
             if (resp.detail) {
               throw TalerError.fromUncheckedDetail(resp.detail);
             } else {
-              throw TalerError.fromException(new Error("failed to get 
conversion cashout rate"))
+              throw TalerError.fromException(
+                new Error("failed to get conversion cashout rate"),
+              );
             }
         }
       }
@@ -207,7 +213,9 @@ export function useCashoutEstimator(): ConversionEstimators 
{
             if (resp.detail) {
               throw TalerError.fromUncheckedDetail(resp.detail);
             } else {
-              throw TalerError.fromException(new Error("failed to get 
conversion cashout rate"))
+              throw TalerError.fromException(
+                new Error("failed to get conversion cashout rate"),
+              );
             }
         }
       }
@@ -484,6 +492,7 @@ export function useLastMonitorInfo(
     lib: { bank: api },
   } = useBankCoreApiContext();
   const { state: credentials } = useSessionState();
+
   const token =
     credentials.status !== "loggedIn" ? undefined : credentials.token;
 
diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx 
b/packages/bank-ui/src/pages/admin/AdminHome.tsx
index 863886605..1f8b1ad18 100644
--- a/packages/bank-ui/src/pages/admin/AdminHome.tsx
+++ b/packages/bank-ui/src/pages/admin/AdminHome.tsx
@@ -187,7 +187,7 @@ export function getTimeframesForDate(
           sub(time, { days: 1 }).getTime(),
         ),
         previous: AbsoluteTime.fromMilliseconds(
-          sub(time, { days: 4 }).getTime(),
+          sub(time, { days: 2 }).getTime(),
         ),
       };
     case TalerCorebankApi.MonitorTimeframeParam.month:
@@ -241,11 +241,10 @@ function Metrics({
   if (resp instanceof TalerError) {
     return <ErrorLoadingWithDebug error={resp} />;
   }
-  if (!respInfo) return <Fragment />;
-  if (respInfo instanceof TalerError) {
+  if (respInfo && respInfo instanceof TalerError) {
     return <ErrorLoadingWithDebug error={respInfo} />;
   }
-  if (respInfo.type === "fail") {
+  if (respInfo && respInfo.type === "fail") {
     switch (respInfo.case) {
       case HttpStatusCode.NotImplemented: {
         return (
@@ -483,7 +482,8 @@ function Metrics({
         </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" ||
+        {!respInfo ||
+        resp.current.body.type !== "with-conversions" ||
         resp.previous.body.type !== "with-conversions" ? undefined : (
           <Fragment>
             <div class="px-4 py-5 sm:p-6">
@@ -496,7 +496,7 @@ function Metrics({
                   </i18n.Translate>
                 </div>
               </dt>
-              <MetricValue
+              <MetricValueAmount
                 current={resp.current.body.cashinFiatVolume}
                 previous={resp.previous.body.cashinFiatVolume}
                 spec={respInfo.body.fiat_currency_specification}
@@ -512,7 +512,7 @@ function Metrics({
                   account.
                 </i18n.Translate>
               </div>
-              <MetricValue
+              <MetricValueAmount
                 current={resp.current.body.cashoutFiatVolume}
                 previous={resp.previous.body.cashoutFiatVolume}
                 spec={respInfo.body.fiat_currency_specification}
@@ -529,7 +529,7 @@ function Metrics({
               </i18n.Translate>
             </div>
           </dt>
-          <MetricValue
+          <MetricValueAmount
             current={resp.current.body.talerInVolume}
             previous={resp.previous.body.talerInVolume}
             spec={config.currency_specification}
@@ -544,12 +544,40 @@ function Metrics({
               </i18n.Translate>
             </div>
           </dt>
-          <MetricValue
+          <MetricValueAmount
             current={resp.current.body.talerOutVolume}
             previous={resp.previous.body.talerOutVolume}
             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>Payin</i18n.Translate>
+            <div class="text-xs text-gray-500">
+              <i18n.Translate>
+                Transferred from an account to a Taler exchange.
+              </i18n.Translate>
+            </div>
+          </dt>
+          <MetricValueNumber
+            current={resp.current.body.talerInCount}
+            previous={resp.previous.body.talerInCount}
+          />
+        </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>
+          <MetricValueNumber
+            current={resp.current.body.talerOutCount}
+            previous={resp.previous.body.talerOutCount}
+          />
+        </div>
       </dl>
       <div class="flex justify-end mt-4">
         <a
@@ -564,7 +592,7 @@ function Metrics({
   );
 }
 
-function MetricValue({
+function MetricValueAmount({
   current,
   previous,
   spec,
@@ -678,3 +706,93 @@ function MetricValue({
     </Fragment>
   );
 }
+
+function MetricValueNumber({
+  current,
+  previous,
+}: {
+  current: number | undefined;
+  previous: number | undefined;
+}): VNode {
+  const { i18n } = useTranslationContext();
+
+  const cmp = current && previous ? (current < previous ? -1 : 1) : 0;
+
+  const rate =
+    !current || Number.isNaN(current) || !previous || Number.isNaN(previous)
+      ? 0
+      : cmp === -1
+        ? 1 - Math.round(current) / Math.round(previous)
+        : cmp === 1
+          ? Math.round(current) / Math.round(previous) - 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 ? "-" : current}
+        </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>previous</i18n.Translate>{" "}
+              {!previous ? "-" : previous}
+            </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>
+  );
+}
diff --git a/packages/taler-util/src/http-client/exchange.ts 
b/packages/taler-util/src/http-client/exchange.ts
index 1075921dc..4e30882c7 100644
--- a/packages/taler-util/src/http-client/exchange.ts
+++ b/packages/taler-util/src/http-client/exchange.ts
@@ -81,6 +81,7 @@ import { TalerError } from "../errors.js";
 import { TalerErrorCode } from "../taler-error-codes.js";
 import { codecForEmptyObject } from "../types-taler-wallet.js";
 import { canonicalJson } from "../helpers.js";
+import { AbsoluteTime } from "../time.js";
 
 export type TalerExchangeResultByMethod<
   prop extends keyof TalerExchangeHttpClient,
@@ -861,17 +862,17 @@ export class TalerExchangeHttpClient {
     auth: OfficerAccount,
     name: string,
     filter: {
-      since?: Date;
-      until?: Date;
+      since?: AbsoluteTime;
+      until?: AbsoluteTime;
     } = {},
   ) {
     const url = new URL(`aml/${auth.id}/kyc-statistics/${name}`, this.baseUrl);
 
-    if (filter.since !== undefined) {
-      url.searchParams.set("start_date", String(filter.since.getTime()));
+    if (filter.since !== undefined && filter.since.t_ms !== "never") {
+      url.searchParams.set("start_date", String(filter.since.t_ms));
     }
-    if (filter.until !== undefined) {
-      url.searchParams.set("end_date", String(filter.until.getTime()));
+    if (filter.until !== undefined && filter.until.t_ms !== "never") {
+      url.searchParams.set("end_date", String(filter.until.t_ms));
     }
 
     const resp = await this.httpLib.fetch(url.href, {
diff --git a/packages/taler-util/src/types-taler-exchange.ts 
b/packages/taler-util/src/types-taler-exchange.ts
index c8d0c925a..f78c71e04 100644
--- a/packages/taler-util/src/types-taler-exchange.ts
+++ b/packages/taler-util/src/types-taler-exchange.ts
@@ -1610,6 +1610,12 @@ export type AmlDecisionRequestWithoutSignature = Omit<
   "officer_sig"
 >;
 
+export enum AmlSpaDialect {
+  TOPS = "tops",
+  GLS = "gls",
+  TESTING = "testing",
+}
+
 export interface ExchangeVersionResponse {
   // libtool-style representation of the Exchange protocol version, see
   // 
https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning
@@ -1638,7 +1644,7 @@ export interface ExchangeVersionResponse {
   // to show and sets of properties/events to trigger in
   // AML decisions.
   // @since protocol **v24**.
-  aml_spa_dialect?: string;
+  aml_spa_dialect?: AmlSpaDialect;
 }
 
 export interface WireAccount {
@@ -1780,16 +1786,6 @@ export interface AccountKycStatus {
   limits?: AccountLimit[];
 }
 
-export type LimitOperationType2 =
-  | "WITHDRAW"
-  | "DEPOSIT"
-  | "MERGE"
-  | "AGGREGATE"
-  | "BALANCE"
-  | "REFUND"
-  | "CLOSE"
-  | "TRANSACTION";
-
 export enum LimitOperationType {
   withdraw = "WITHDRAW",
   deposit = "DEPOSIT",
@@ -2446,6 +2442,12 @@ interface CSDenominationKey {
   cs_public_key: Cs25519Point;
 }
 
+export const codecForAmlSpaDialect = codecForEither(
+  codecForConstString(AmlSpaDialect.GLS),
+  codecForConstString(AmlSpaDialect.TOPS),
+  codecForConstString(AmlSpaDialect.TESTING),
+);
+
 export const codecForExchangeConfig = (): Codec<ExchangeVersionResponse> =>
   buildCodecForObject<ExchangeVersionResponse>()
     .property("version", codecForString())
@@ -2454,7 +2456,7 @@ export const codecForExchangeConfig = (): 
Codec<ExchangeVersionResponse> =>
     .property("currency", codecForString())
     .property("currency_specification", codecForCurrencySpecificiation())
     .property("supported_kyc_requirements", codecForList(codecForString()))
-    .property("aml_spa_dialect", codecOptional(codecForString()))
+    .property("aml_spa_dialect", codecOptional(codecForAmlSpaDialect))
     .deprecatedProperty("shopping_url")
     .deprecatedProperty("wallet_balance_limit_without_kyc")
     .build("TalerExchangeApi.ExchangeVersionResponse");

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]