gnunet-svn
[Top][All Lists]
Advanced

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

[taler-typescript-core] branch master updated (696482d3b -> 122d888b3)


From: gnunet
Subject: [taler-typescript-core] branch master updated (696482d3b -> 122d888b3)
Date: Sun, 26 Jan 2025 15:33:55 +0100

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

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

    from 696482d3b new prebuilt dev
     new f586af103 input duration
     new 8f329518e fix when input is undefined, show help text
     new 977787d6a fix measure format, shortcut buttons
     new 122d888b3 summary

The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../src/hooks/decision-request.ts                  |  11 +-
 .../aml-backoffice-ui/src/pages/CaseDetails.tsx    | 265 +++-------------
 packages/aml-backoffice-ui/src/pages/Dashboard.tsx |   8 +-
 packages/aml-backoffice-ui/src/pages/RulesInfo.tsx | 335 +++++++++++++++++++++
 .../pages/decision/AmlDecisionRequestWizard.tsx    |  17 +-
 .../src/pages/decision/Events.tsx                  |   4 +-
 .../src/pages/decision/Justification.tsx           |  43 +--
 .../src/pages/decision/Measures.tsx                |  63 +---
 .../aml-backoffice-ui/src/pages/decision/Rules.tsx | 128 +++++++-
 .../src/pages/decision/Summary.tsx                 | 205 ++++++++++++-
 packages/taler-util/src/time.ts                    |  29 +-
 packages/web-util/src/components/index.ts          |   2 +-
 packages/web-util/src/components/utils.ts          |  24 ++
 packages/web-util/src/forms/field-types.ts         |   7 +
 .../src/forms/fields/InputDuration.stories.tsx     |   8 +-
 .../web-util/src/forms/fields/InputDuration.tsx    | 176 +++++++++--
 ...r.stories.tsx => InputDurationText.stories.tsx} |  15 +-
 .../src/forms/fields/InputDurationText.tsx         | 105 +++++++
 .../src/forms/fields/InputInteger.stories.tsx      |  27 ++
 packages/web-util/src/forms/fields/InputLine.tsx   |  16 +-
 .../forms/fields/InputSelectMultiple.stories.tsx   |   1 +
 .../src/forms/fields/InputSelectMultiple.tsx       |  10 +-
 .../src/forms/fields/InputText.stories.tsx         |   5 +
 packages/web-util/src/forms/fields/InputToggle.tsx |   5 +
 packages/web-util/src/forms/forms-types.ts         |  11 +
 packages/web-util/src/forms/forms-utils.ts         |  13 +
 packages/web-util/src/forms/index.stories.ts       |   1 +
 packages/web-util/src/stories-utils.tsx            |  14 +-
 28 files changed, 1150 insertions(+), 398 deletions(-)
 create mode 100644 packages/aml-backoffice-ui/src/pages/RulesInfo.tsx
 copy packages/web-util/src/forms/fields/{InputInteger.stories.tsx => 
InputDurationText.stories.tsx} (83%)
 create mode 100644 packages/web-util/src/forms/fields/InputDurationText.tsx

diff --git a/packages/aml-backoffice-ui/src/hooks/decision-request.ts 
b/packages/aml-backoffice-ui/src/hooks/decision-request.ts
index 34c2aeda3..fd191df37 100644
--- a/packages/aml-backoffice-ui/src/hooks/decision-request.ts
+++ b/packages/aml-backoffice-ui/src/hooks/decision-request.ts
@@ -35,9 +35,9 @@ import { buildStorageKey, useLocalStorage } from 
"@gnu-taler/web-util/browser";
 
 export interface DecisionRequest {
   rules: KycRule[] | undefined;
-  new_measures: string | undefined;
+  new_measures: string[] | undefined;
   deadline: AbsoluteTime | undefined;
-  onExpire_measures: string | undefined;
+  onExpire_measures: string[] | undefined;
   properties: Record<string, any> | undefined;
   custom_properties: Record<string, any> | undefined;
   custom_events: string[] | undefined;
@@ -59,8 +59,11 @@ export const codecForDecisionRequest = (): 
Codec<DecisionRequest> =>
       "keep_investigating",
       codecOptionalDefault(codecForBoolean(), false),
     )
-    .property("new_measures", codecOptional(codecForString()))
-    .property("onExpire_measures", codecOptional(codecForString()))
+    .property("new_measures", codecOptional(codecForList(codecForString())))
+    .property(
+      "onExpire_measures",
+      codecOptional(codecForList(codecForString())),
+    )
     .build("DecisionRequest");
 
 const defaultDecisionRequest: DecisionRequest = {
diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx 
b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
index 68c037cc7..21dad967a 100644
--- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
+++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
@@ -52,7 +52,7 @@ import {
   useLocalNotificationHandler,
   useTranslationContext,
 } from "@gnu-taler/web-util/browser";
-import { format, formatDuration, intervalToDuration } from "date-fns";
+import { format } from "date-fns";
 import { Fragment, h, Ref, VNode } from "preact";
 import { useState } from "preact/hooks";
 import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
@@ -66,6 +66,7 @@ import { CurrentMeasureTable, MeasureInfo } from 
"./MeasuresTable.js";
 import { Officer } from "./Officer.js";
 import { ShowConsolidated } from "./ShowConsolidated.js";
 import { useServerMeasures } from "../hooks/server-info.js";
+import { RulesInfo } from "./RulesInfo.js";
 
 export type AmlEvent =
   | AmlFormEvent
@@ -703,8 +704,8 @@ function ShowMesaureInfo({
   nextMeasures: string[][];
   customMeasure: { [d: string]: TalerExchangeApi.MeasureInformation };
 }): VNode {
-  const measures = useServerMeasures();
   const { i18n } = useTranslationContext();
+  const measures = useServerMeasures();
   if (!measures) {
     return <Loading />;
   }
@@ -730,32 +731,7 @@ function ShowMesaureInfo({
         assertUnreachable(measures.case);
     }
   }
-  const summary: TalerExchangeApi.AvailableMeasureSummary = measures.body;
-
-  const map: { [d: string]: MeasureInfo } = {};
-
-  function addUpIntoMap([key, value]: [
-    string,
-    TalerExchangeApi.MeasureInformation,
-  ]): void {
-    if (value.check_name !== "SKIP") {
-      map[key] = {
-        name: key,
-        context: value.context,
-        program: summary.programs[value.prog_name],
-        check: summary.checks[value.check_name],
-      };
-    } else {
-      map[key] = {
-        name: key,
-        context: value.context,
-        program: summary.programs[value.prog_name],
-      };
-    }
-  }
-
-  Object.entries(measures.body.roots).forEach(addUpIntoMap);
-  Object.entries(customMeasure).forEach(addUpIntoMap);
+  const map = computeAvailableMesaures(measures.body, customMeasure);
 
   const filteredMeasures = nextMeasures.filter((n) => !!n.length);
 
@@ -797,169 +773,6 @@ function ShowMesaureInfo({
   );
 }
 
-export function RulesInfo({
-  rules,
-  onEdit,
-  onRemove,
-}: {
-  rules: KycRule[];
-  onEdit?: (k: KycRule, idx: number) => void;
-  onRemove?: (k: KycRule, idx: number) => void;
-}): VNode {
-  const { i18n } = useTranslationContext();
-  const { config } = useExchangeApiContext();
-
-  if (!rules.length) {
-    return (
-      <Attention
-        title={i18n.str`There are no rules for operations`}
-        type="warning"
-      />
-    );
-  }
-
-  const balanceLimitIdx = rules.findIndex(
-    (r) => r.operation_type === "BALANCE",
-  );
-  const balanceLimit = rules[balanceLimitIdx];
-
-  const hasActions = !!onEdit || !!onRemove;
-
-  return (
-    <Fragment>
-      <div class="">
-        <div class="flex mt-2 rounded-md w-fit  shadow-sm border-0 ring-1 
ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600">
-          <div class="whitespace-nowrap pointer-events-none bg-gray-200 
inset-y-0 items-center px-3 flex">
-            <i18n.Translate>Max balance</i18n.Translate>
-          </div>
-          <div class="p-2  disabled:bg-gray-200 text-right rounded-md 
rounded-l-none data-[left=true]:text-left py-1.5 pl-3 text-gray-900  
placeholder:text-gray-400  sm:text-sm sm:leading-6">
-            {!balanceLimit ? (
-              <i18n.Translate>Unlimited</i18n.Translate>
-            ) : (
-              <RenderAmount
-                value={Amounts.parseOrThrow(balanceLimit.threshold)}
-                spec={config.config.currency_specification}
-              />
-            )}
-          </div>
-        </div>
-      </div>
-      <div class="">
-        <table class="min-w-full divide-y divide-gray-300">
-          <thead class="bg-gray-50">
-            <tr>
-              <th
-                scope="col"
-                class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold 
text-gray-900 sm:pl-6"
-              >
-                <i18n.Translate>Operation</i18n.Translate>
-              </th>
-              <th
-                scope="col"
-                class="px-3 py-3.5 text-left text-sm font-semibold 
text-gray-900"
-              >
-                <i18n.Translate>Timeframe</i18n.Translate>
-              </th>
-              <th
-                scope="col"
-                class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
-              >
-                <i18n.Translate>Amount</i18n.Translate>
-              </th>
-              <th
-                scope="col"
-                class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
-              >
-                <i18n.Translate>Measures</i18n.Translate>
-              </th>
-              {!hasActions ? undefined : (
-                <th
-                  scope="col"
-                  class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
-                >
-                  <i18n.Translate>Actions</i18n.Translate>
-                </th>
-              )}
-            </tr>
-          </thead>
-          <tbody class="divide-y divide-gray-200">
-            {rules.map((r, idx) => {
-              if (r.operation_type === "BALANCE") return;
-              return (
-                <tr>
-                  <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm 
font-medium text-gray-900 sm:pl-6 text-left">
-                    {r.operation_type}
-                  </td>
-                  <td class="whitespace-nowrap px-3 py-4 text-sm 
text-gray-500">
-                    {r.timeframe.d_us === "forever" ? (
-                      <i18n.Translate>Forever</i18n.Translate>
-                    ) : (
-                      formatDuration(
-                        intervalToDuration({
-                          start: 0,
-                          end: r.timeframe.d_us / 1000,
-                        }),
-                      )
-                    )}
-                  </td>
-                  <td class=" relative whitespace-nowrap py-4 pl-3 pr-4 
text-sm font-medium sm:pr-6 text-right">
-                    <RenderAmount
-                      value={Amounts.parseOrThrow(r.threshold)}
-                      spec={config.config.currency_specification}
-                    />
-                  </td>
-                  <td class=" relative whitespace-nowrap py-4 pl-3 pr-4 
text-sm font-medium sm:pr-6 text-right">
-                    {r.measures}
-                  </td>
-                  {!hasActions ? undefined : (
-                    <td class="relative flex justify-end whitespace-nowrap 
py-4 pl-3 pr-4 text-sm font-medium sm:pr-6">
-                      {!onEdit ? undefined : (
-                        <button onClick={() => onEdit(r, idx)}>
-                          <svg
-                            xmlns="http://www.w3.org/2000/svg";
-                            fill="none"
-                            viewBox="0 0 24 24"
-                            stroke-width="1.5"
-                            stroke="currentColor"
-                            class="size-6 text-green-700"
-                          >
-                            <path
-                              stroke-linecap="round"
-                              stroke-linejoin="round"
-                              d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 
2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 
1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 
21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
-                            />
-                          </svg>
-                        </button>
-                      )}
-                      {!onRemove ? undefined : (
-                        <button onClick={() => onRemove(r, idx)}>
-                          <svg
-                            xmlns="http://www.w3.org/2000/svg";
-                            fill="none"
-                            viewBox="0 0 24 24"
-                            stroke-width="1.5"
-                            stroke="currentColor"
-                            class="size-6 text-red-700"
-                          >
-                            <path
-                              stroke-linecap="round"
-                              stroke-linejoin="round"
-                              d="m14.74 9-.346 9m-4.788 0L9.26 
9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 
1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 
48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 
0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 
0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
-                            />
-                          </svg>
-                        </button>
-                      )}
-                    </td>
-                  )}
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
-      </div>
-    </Fragment>
-  );
-}
 export function ShowDecisionLimitInfo({
   rules,
   since,
@@ -1094,58 +907,27 @@ export function ShowDecisionLimitInfo({
   );
 }
 
-export function RenderAmount({
-  value,
-  spec,
-  negative,
-  withColor,
-  hideSmall,
-}: {
-  spec: CurrencySpecification;
-  value: AmountJson;
-  hideSmall?: boolean;
-  negative?: boolean;
-  withColor?: boolean;
-}): VNode {
-  const neg = !!negative; // convert to true or false
-
-  const { currency, normal, small } = Amounts.stringifyValueWithSpec(
-    value,
-    spec,
-  );
-
-  return (
-    <span
-      data-negative={withColor ? neg : undefined}
-      class="whitespace-nowrap data-[negative=false]:text-green-600 
data-[negative=true]:text-red-600"
-    >
-      {negative ? "- " : undefined}
-      {currency} {normal}{" "}
-      {!hideSmall && small && <sup class="-ml-1">{small}</sup>}
-    </span>
-  );
-}
-
 function AmlStateBadge({ state }: { state: TalerExchangeApi.AmlState }): VNode 
{
+  const { i18n } = useTranslationContext();
   switch (state) {
     case TalerExchangeApi.AmlState.normal: {
       return (
         <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 
text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
-          Normal
+          <i18n.Translate>Normal</i18n.Translate>
         </span>
       );
     }
     case TalerExchangeApi.AmlState.pending: {
       return (
         <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 
py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20">
-          Pending
+          <i18n.Translate>Pending</i18n.Translate>
         </span>
       );
     }
     case TalerExchangeApi.AmlState.frozen: {
       return (
         <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 
text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20">
-          Frozen
+          <i18n.Translate>Frozen</i18n.Translate>
         </span>
       );
     }
@@ -1690,3 +1472,34 @@ export function ShowMeasuresToSelect({
 
   return <CurrentMeasureTable list={list} onSelect={onSelect} />;
 }
+
+export function computeAvailableMesaures(
+  server: TalerExchangeApi.AvailableMeasureSummary,
+  custom: TalerExchangeApi.AvailableMeasureSummary["roots"],
+): { [name: string]: MeasureInfo } {
+  const result: { [d: string]: MeasureInfo } = {};
+
+  function addUpIntoMap([key, value]: [
+    string,
+    TalerExchangeApi.MeasureInformation,
+  ]): void {
+    if (value.check_name !== "SKIP") {
+      result[key] = {
+        name: key,
+        context: value.context,
+        program: server.programs[value.prog_name],
+        check: server.checks[value.check_name],
+      };
+    } else {
+      result[key] = {
+        name: key,
+        context: value.context,
+        program: server.programs[value.prog_name],
+      };
+    }
+  }
+  Object.entries(server.roots).forEach(addUpIntoMap);
+  Object.entries(custom).forEach(addUpIntoMap);
+
+  return result;
+}
diff --git a/packages/aml-backoffice-ui/src/pages/Dashboard.tsx 
b/packages/aml-backoffice-ui/src/pages/Dashboard.tsx
index e87236cfc..e43934420 100644
--- a/packages/aml-backoffice-ui/src/pages/Dashboard.tsx
+++ b/packages/aml-backoffice-ui/src/pages/Dashboard.tsx
@@ -95,7 +95,7 @@ function EventMetrics({
       <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>
+            <i18n.Translate>Statistics</i18n.Translate>
           </h1>
         </div>
       </div>
@@ -104,7 +104,7 @@ function EventMetrics({
 
       <div class="w-full flex justify-between">
         <h1 class="text-base text-gray-900 mt-5">
-          {i18n.str`Trading volume from ${getDateStringForTimeframe(
+          {i18n.str`Events from ${getDateStringForTimeframe(
             params.current.start,
             metricType,
             dateLocale,
@@ -258,13 +258,13 @@ function MetricValueNumber({
     <Fragment>
       <dd class="mt-1 block ">
         <div class="flex justify-start text-2xl items-baseline font-semibold 
text-indigo-600">
-          {!current ? "-" : current}
+          {!current ? 0 : 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}
+              {!previous ? 0 : previous}
             </small>
           </div>
           {!!rate && (
diff --git a/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx 
b/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx
new file mode 100644
index 000000000..039df4971
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx
@@ -0,0 +1,335 @@
+import {
+  amountFractionalBase,
+  AmountJson,
+  Amounts,
+  assertUnreachable,
+  CurrencySpecification,
+  KycRule,
+  LimitOperationType,
+} from "@gnu-taler/taler-util";
+import {
+  Attention,
+  useExchangeApiContext,
+  useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { formatDuration, intervalToDuration } from "date-fns";
+import { Fragment, h, VNode } from "preact";
+
+export function RulesInfo({
+  rules,
+  onEdit,
+  onRemove,
+}: {
+  rules: KycRule[];
+  onEdit?: (k: KycRule, idx: number) => void;
+  onRemove?: (k: KycRule, idx: number) => void;
+}): VNode {
+  const { i18n } = useTranslationContext();
+  const { config } = useExchangeApiContext();
+
+  if (!rules.length) {
+    return (
+      <Attention
+        title={i18n.str`There are no rules for operations`}
+        type="warning"
+      >
+        <i18n.Translate>
+          This mean that all operation have no limit.
+        </i18n.Translate>
+      </Attention>
+    );
+  }
+
+  const OPERATION_TYPE_MISSING = {
+    [LimitOperationType.balance]: true,
+    [LimitOperationType.transaction]: true,
+    [LimitOperationType.withdraw]: true,
+    [LimitOperationType.deposit]: true,
+    [LimitOperationType.aggregate]: true,
+    [LimitOperationType.close]: true,
+    [LimitOperationType.refund]: true,
+    [LimitOperationType.merge]: true,
+  };
+
+  const sorted = [...rules].sort((a, b) => {
+    console.log(a.operation_type);
+    // to prevent iterate again we are using this sort function
+    // to save present operation type
+    OPERATION_TYPE_MISSING[a.operation_type] = false;
+    OPERATION_TYPE_MISSING[b.operation_type] = false;
+    return sortKycRules(a, b);
+  });
+  if (rules.length === 1) {
+    // if there is only one element, sort function is not called
+    OPERATION_TYPE_MISSING[rules[0].operation_type] = false;
+  }
+
+  console.log(OPERATION_TYPE_MISSING);
+  const missing = Object.entries(OPERATION_TYPE_MISSING)
+    .filter(([key, value]) => !!value)
+    .map(([key]) => key) as LimitOperationType[];
+  console.log(missing);
+
+  const hasActions = !!onEdit || !!onRemove;
+
+  return (
+    <Fragment>
+      <div class="">
+        <table class="min-w-full divide-y divide-gray-300">
+          <thead class="bg-gray-50">
+            <tr>
+              <th
+                scope="col"
+                class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold 
text-gray-900 sm:pl-6"
+              >
+                <i18n.Translate>Operation</i18n.Translate>
+              </th>
+              <th
+                scope="col"
+                class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
+              >
+                <i18n.Translate>Threshold</i18n.Translate>
+              </th>
+              <th
+                scope="col"
+                class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
+              >
+                <i18n.Translate>Escalation</i18n.Translate>
+              </th>
+              {!hasActions ? undefined : (
+                <th
+                  scope="col"
+                  class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
+                >
+                  {/* <i18n.Translate>Actions</i18n.Translate> */}
+                </th>
+              )}
+            </tr>
+          </thead>
+
+          <tbody id="thetable" class="divide-y divide-gray-200 bg-white ">
+            {sorted.map((r, idx) => {
+              return (
+                <tr class="even:bg-gray-200 ">
+                  <td class="flex whitespace-nowrap py-2 pl-4 pr-3 text-sm 
font-medium text-gray-900 sm:pl-6 text-left">
+                    <span class="mx-2">
+                      {r.exposed ? (
+                        <svg
+                          xmlns="http://www.w3.org/2000/svg";
+                          fill="none"
+                          viewBox="0 0 24 24"
+                          stroke-width="1.5"
+                          stroke="currentColor"
+                          class="size-6"
+                        >
+                          <path
+                            stroke-linecap="round"
+                            stroke-linejoin="round"
+                            d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 
7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 
.639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
+                          />
+                          <path
+                            stroke-linecap="round"
+                            stroke-linejoin="round"
+                            d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
+                          />
+                        </svg>
+                      ) : (
+                        <svg
+                          xmlns="http://www.w3.org/2000/svg";
+                          fill="none"
+                          viewBox="0 0 24 24"
+                          stroke-width="1.5"
+                          stroke="currentColor"
+                          class="size-6 text-gray-500"
+                        >
+                          <path
+                            stroke-linecap="round"
+                            stroke-linejoin="round"
+                            d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 
16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 
10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 
5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 
21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
+                          />
+                        </svg>
+                      )}
+                    </span>
+                    <span>{r.operation_type}</span>
+                  </td>
+                  <td class=" relative whitespace-nowrap py-2 pl-3 pr-4 
text-sm font-medium sm:pr-6 text-right">
+                    {r.timeframe.d_us === "forever" ? (
+                      <RenderAmount
+                        value={Amounts.parseOrThrow(r.threshold)}
+                        spec={config.config.currency_specification}
+                      />
+                    ) : (
+                      <i18n.Translate context="threshold">
+                        <RenderAmount
+                          value={Amounts.parseOrThrow(r.threshold)}
+                          spec={config.config.currency_specification}
+                        />
+                        every{" "}
+                        {formatDuration(
+                          intervalToDuration({
+                            start: 0,
+                            end: r.timeframe.d_us / 1000,
+                          }),
+                        )}
+                      </i18n.Translate>
+                    )}
+                  </td>
+                  <td class=" relative whitespace-nowrap py-2 pl-3 pr-4 
text-sm font-medium sm:pr-6 text-right">
+                    {r.is_and_combinator ? (
+                      <span class="text-gray-500">
+                        <i18n.Translate>(all)</i18n.Translate>
+                      </span>
+                    ) : (
+                      <Fragment />
+                    )}
+                    {r.measures}
+                  </td>
+                  {!hasActions ? undefined : (
+                    <td class="relative flex justify-end whitespace-nowrap 
py-2 pl-3 pr-4 text-sm font-medium sm:pr-6">
+                      {!onEdit ? undefined : (
+                        <button onClick={() => onEdit(r, idx)}>
+                          <svg
+                            xmlns="http://www.w3.org/2000/svg";
+                            fill="none"
+                            viewBox="0 0 24 24"
+                            stroke-width="1.5"
+                            stroke="currentColor"
+                            class="size-6 text-green-700"
+                          >
+                            <path
+                              stroke-linecap="round"
+                              stroke-linejoin="round"
+                              d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 
2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 
1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 
21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
+                            />
+                          </svg>
+                        </button>
+                      )}
+                      {!onRemove ? undefined : (
+                        <button onClick={() => onRemove(r, idx)}>
+                          <svg
+                            xmlns="http://www.w3.org/2000/svg";
+                            fill="none"
+                            viewBox="0 0 24 24"
+                            stroke-width="1.5"
+                            stroke="currentColor"
+                            class="size-6 text-red-700"
+                          >
+                            <path
+                              stroke-linecap="round"
+                              stroke-linejoin="round"
+                              d="m14.74 9-.346 9m-4.788 0L9.26 
9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 
1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 
48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 
0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 
0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
+                            />
+                          </svg>
+                        </button>
+                      )}
+                    </td>
+                  )}
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+        {!missing.length ? undefined : missing.length === 1 ? (
+          <Attention
+            type="warning"
+            title={i18n.str`There is an operation without limit`}
+          >
+            <i18n.Translate>
+              This mean that this operation can be used without limit:{" "}
+              {missing.join(", ")}
+            </i18n.Translate>
+          </Attention>
+        ) : (
+          <Attention
+            type="warning"
+            title={i18n.str`There are operations without limit`}
+          >
+            <i18n.Translate>
+              This mean that these operations can be used without limit:{" "}
+              {missing.join(", ")}
+            </i18n.Translate>
+          </Attention>
+        )}
+      </div>
+    </Fragment>
+  );
+}
+
+function RenderAmount({
+  value,
+  spec,
+  negative,
+  withColor,
+  hideSmall,
+}: {
+  spec: CurrencySpecification;
+  value: AmountJson;
+  hideSmall?: boolean;
+  negative?: boolean;
+  withColor?: boolean;
+}): VNode {
+  const neg = !!negative; // convert to true or false
+
+  const { currency, normal, small } = Amounts.stringifyValueWithSpec(
+    value,
+    spec,
+  );
+
+  return (
+    <span
+      data-negative={withColor ? neg : undefined}
+      class="whitespace-nowrap data-[negative=false]:text-green-600 
data-[negative=true]:text-red-600"
+    >
+      {negative ? "- " : undefined}
+      {currency} {normal}{" "}
+      {!hideSmall && small && <sup class="-ml-1">{small}</sup>}
+    </span>
+  );
+}
+
+export function rate(a: AmountJson, b: number): number {
+  const af = toFloat(a);
+  const bf = b;
+  if (bf === 0) return 0;
+  return af / bf;
+}
+
+function toFloat(amount: AmountJson): number {
+  return amount.value + amount.fraction / amountFractionalBase;
+}
+
+const OPERATION_TYPE_ORDER = {
+  [LimitOperationType.balance]: 1,
+  [LimitOperationType.transaction]: 2,
+  [LimitOperationType.withdraw]: 3,
+  [LimitOperationType.deposit]: 4,
+  [LimitOperationType.aggregate]: 5,
+  [LimitOperationType.close]: 6,
+  [LimitOperationType.refund]: 7,
+  [LimitOperationType.merge]: 8,
+} as const;
+
+/**
+ * Operation follows OPERATION_TYPE_ORDER.
+ * Then operations with timeframe "forever" means they are not reset, like 
balance. Go first.
+ * Then operations with high throughput first.
+ * @param a
+ * @param b
+ * @returns
+ */
+function sortKycRules(a: KycRule, b: KycRule): number {
+  const op =
+    OPERATION_TYPE_ORDER[a.operation_type] -
+    OPERATION_TYPE_ORDER[b.operation_type];
+  if (op !== 0) return op;
+  const at = a.timeframe;
+  const bt = b.timeframe;
+  if (at.d_us === "forever" || bt.d_us === "forever") {
+    if (at.d_us === "forever") return -1;
+    if (bt.d_us === "forever") return 1;
+    return Amounts.cmp(a.threshold, b.threshold);
+  }
+  const as = rate(Amounts.parseOrThrow(a.threshold), at.d_us);
+  const bs = rate(Amounts.parseOrThrow(a.threshold), bt.d_us);
+  return bs - as;
+}
diff --git 
a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx
index b677e2a0d..e0fc9a7c9 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx
@@ -69,23 +69,20 @@ const STEPS_ORDER_MAP = STEPS_ORDER.reduce(
   },
 );
 
-function isRulesCompleted(request: DecisionRequest): boolean {
+export function isRulesCompleted(request: DecisionRequest): boolean {
   return request.rules !== undefined;
 }
-function isPropertiesCompleted(request: DecisionRequest): boolean {
+export function isPropertiesCompleted(request: DecisionRequest): boolean {
   return request.properties !== undefined;
 }
-function isEventsCompleted(request: DecisionRequest): boolean {
+export function isEventsCompleted(request: DecisionRequest): boolean {
   return request.custom_events !== undefined;
 }
-function isMeasuresCompleted(request: DecisionRequest): boolean {
+export function isMeasuresCompleted(request: DecisionRequest): boolean {
   return request.new_measures !== undefined;
 }
-function isJustificationCompleted(request: DecisionRequest): boolean {
-  return (
-    request.keep_investigating !== undefined &&
-    request.justification !== undefined
-  );
+export function isJustificationCompleted(request: DecisionRequest): boolean {
+  return request.keep_investigating !== undefined && !!request.justification;
 }
 
 export function AmlDecisionRequestWizard({
@@ -112,7 +109,7 @@ export function AmlDecisionRequestWizard({
       case "justification":
         return <Justification />;
       case "summary":
-        return <Summary />;
+        return <Summary account={account} />;
     }
     assertUnreachable(stepOrDefault);
   })();
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx
index a984b430a..8ed44d55c 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx
@@ -65,7 +65,7 @@ export function Events({}: {}): VNode {
         const isInhibit =
           request.inhibit_events !== undefined &&
           request.inhibit_events.indexOf(cur.id) !== -1;
-        prev[cur.id] = isInhibit;
+        prev[cur.id] = !isInhibit;
         return prev;
       },
       {} as FormType["inhibit"],
@@ -82,7 +82,7 @@ export function Events({}: {}): VNode {
         ? []
         : form.status.result.trigger.map((t) => t?.name!),
       inhibit_events: Object.entries(form.status.result.inhibit ?? {})
-        .filter(([key, inhibit]) => !!inhibit)
+        .filter(([key, inhibit]) => !inhibit)
         .map(([key]) => key),
     });
   });
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx
index a059d755d..08b4caf9a 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx
@@ -1,3 +1,9 @@
+import {
+  AbsoluteTime,
+  Duration,
+  MeasureInformation,
+  TalerError,
+} from "@gnu-taler/taler-util";
 import {
   FormDesign,
   FormUI,
@@ -9,18 +15,6 @@ import {
 } 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";
 
 /**
@@ -38,9 +32,9 @@ export function Justification({}: {}): VNode {
       : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi }));
   const design = formDesign(i18n, measureList);
 
-  const expMeasres: MeasurePath[] = !request.onExpire_measures
+  const expMeasres: string[] = !request.onExpire_measures
     ? []
-    : deserializeMeasures(request.onExpire_measures);
+    : request.onExpire_measures;
 
   const form = useForm<FormType>(design, {
     investigate: request.keep_investigating,
@@ -54,9 +48,7 @@ export function Justification({}: {}): VNode {
       ...request,
       keep_investigating: !!form.status.result.investigate,
       justification: form.status.result.justification ?? "",
-      onExpire_measures: serializeMeasures(
-        (form.status.result.measures ?? []) as MeasurePath[],
-      ),
+      onExpire_measures: (form.status.result.measures ?? []) as string[],
 
       deadline:
         (form.status.result.expiration as AbsoluteTime) ?? 
AbsoluteTime.never(),
@@ -75,7 +67,7 @@ type FormType = {
   justification: string;
   investigate: boolean;
   expiration: AbsoluteTime;
-  measures: MeasurePath[];
+  measures: string[];
 };
 
 const formDesign = (
@@ -87,6 +79,7 @@ const formDesign = (
     {
       id: "justification" as UIHandlerId,
       type: "textArea",
+      required: true,
       label: i18n.str`Justification`,
     },
     {
@@ -122,6 +115,18 @@ const formDesign = (
       pattern: "dd/MM/yyyy",
       label: i18n.str`Expiration`,
     },
-    measureArrayField(i18n, mi),
+    {
+      type: "selectMultiple",
+      choices: mi.map((m) => {
+        return {
+          value: m.id,
+          label: m.id,
+        };
+      }),
+      unique: true,
+      id: "measures" as UIHandlerId,
+      label: i18n.str`Expiration measure`,
+      help: i18n.str`Measures that the customer will need to satisfy after 
expiration.`,
+    },
   ],
 });
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx
index 0969e733b..376ab3cb3 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx
@@ -15,24 +15,6 @@ 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[]>,
-): string {
-  if (!paths) return "";
-  return paths
-    .map((p) => {
-      if (!p?.steps) return "";
-      return p.steps.join("+");
-    })
-    .join(" ");
-}
-export function deserializeMeasures(
-  measures: string | undefined,
-): MeasurePath[] {
-  if (!measures) return [];
-  return measures.split(" ").map((path) => ({ steps: path.split("+") }));
-}
-
 /**
  * Ask for more information, define new paths to proceed
  * @param param0
@@ -48,24 +30,16 @@ export function Measures({}: {}): VNode {
       : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi }));
 
   const initValue: FormType = !request.new_measures
-    ? { paths: [] }
-    : { paths: deserializeMeasures(request.new_measures) };
+    ? { measures: [] }
+    : { measures: request.new_measures };
 
   const design = formDesign(i18n, measureList);
   const form = useForm<FormType>(design, initValue);
 
   onComponentUnload(() => {
-    const r = !form.status.result.paths
-      ? []
-      : (form.status.result.paths.map(
-          (path) => path?.steps ?? [],
-        ) as string[][]);
-
     updateRequest({
       ...request,
-      new_measures: serializeMeasures(
-        (form.status.result.paths ?? []) as MeasurePath[],
-      ),
+      new_measures: (form.status.result.measures ?? []) as string[],
     });
   });
 
@@ -77,45 +51,30 @@ export function Measures({}: {}): VNode {
   );
 }
 
-export type MeasurePath = { steps: string[] };
-
 type FormType = {
-  paths: MeasurePath[];
+  measures: string[];
 };
 
-export function measureArrayField(
+function formDesign(
   i18n: InternationalizationAPI,
   mi: (MeasureInformation & { id: string })[],
-): UIFormElementConfig {
+): FormDesign<FormType> {
   return {
-    type: "array",
-    id: "paths" as UIHandlerId,
-    label: i18n.str`Measures`,
-    help: i18n.str`For every entry the customer will have a different path to 
satify checks.`,
-    labelFieldId: "steps" as UIHandlerId,
+    type: "single-column",
     fields: [
       {
         type: "selectMultiple",
+        unique: true,
         choices: mi.map((m) => {
           return {
             value: m.id,
             label: m.id,
           };
         }),
-        id: "steps" as UIHandlerId,
-        label: i18n.str`Steps`,
-        help: i18n.str`The checks that the customer will need to satisfy for 
this path.`,
+        id: "measures" as UIHandlerId,
+        label: i18n.str`Active measures`,
+        help: i18n.str`Measures that the customer will need to satisfy while 
the rules are active.`,
       },
     ],
   };
 }
-
-function formDesign(
-  i18n: InternationalizationAPI,
-  mi: (MeasureInformation & { id: string })[],
-): FormDesign<FormType> {
-  return {
-    type: "single-column",
-    fields: [measureArrayField(i18n, mi)],
-  };
-}
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx
index 9bf5657d7..d2a24518c 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx
@@ -22,9 +22,9 @@ import {
 import { h, VNode } from "preact";
 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";
+import { ShowDecisionLimitInfo } from "../CaseDetails.js";
+import { RulesInfo } from "../RulesInfo.js";
 
 /**
  * Defined new limits for the account
@@ -62,12 +62,18 @@ export function Rules({ account }: { account?: string }): 
VNode {
 
   function addNewRule(nr: FormType) {
     const result = !request.rules ? [] : [...request.rules];
+    const clean = (nr.measures ?? []).filter((m) => !!m);
+    const measures = !clean.length ? ["VERBOTEN"] : clean;
     result.push({
-      timeframe: Duration.toTalerProtocolDuration(nr.timeframe),
+      timeframe: !nr.timeframe
+        ? Duration.toTalerProtocolDuration(Duration.getForever())
+        : Duration.toTalerProtocolDuration(nr.timeframe),
       threshold: Amounts.stringify(nr.threshold),
       operation_type: nr.operation_type,
       display_priority: 1,
-      measures: [serializeMeasures(nr.paths)], // FIXME: change how server 
expect new measures
+      exposed: nr.exposed,
+      is_and_combinator: nr.all,
+      measures,
     });
     updateRequest("rules", result);
   }
@@ -83,7 +89,6 @@ export function Rules({ account }: { account?: string }): 
VNode {
       <button
         disabled={form.status.status === "fail"}
         onClick={() => {
-          console.log(form);
           addNewRule(form.status.result as FormType);
         }}
         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 disabled:bg-gray-600"
@@ -95,13 +100,85 @@ export function Rules({ account }: { account?: string }): 
VNode {
         <i18n.Translate>New rules</i18n.Translate>
       </h2>
 
+      <button
+        onClick={() => {
+          updateRequest(
+            "rules",
+            Object.values(LimitOperationType).map((operation_type) => ({
+              display_priority: 1,
+              measures: ["VERBOTEN"],
+              operation_type,
+              threshold: Amounts.stringify(
+                Amounts.zeroOfCurrency(config.config.currency),
+              ),
+              timeframe: Duration.toTalerProtocolDuration(
+                Duration.getForever(),
+              ),
+            })),
+          );
+        }}
+        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 disabled:bg-gray-600"
+      >
+        <i18n.Translate>Freeze account</i18n.Translate>
+      </button>
+      <button
+        onClick={() => {
+          updateRequest(
+            "rules",
+            Object.values(LimitOperationType).map((operation_type) => ({
+              display_priority: 1,
+              measures: ["VERBOTEN"],
+              operation_type,
+              threshold: Amounts.stringify({
+                currency: config.config.currency,
+                fraction: 0,
+                value: 100,
+              }),
+              timeframe: Duration.toTalerProtocolDuration(
+                operation_type === LimitOperationType.transaction ||
+                  operation_type === LimitOperationType.balance
+                  ? Duration.getForever()
+                  : Duration.fromSpec({
+                      months: 1,
+                    }),
+              ),
+            })),
+          );
+        }}
+        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 disabled:bg-gray-600"
+      >
+        <i18n.Translate>Basic plan</i18n.Translate>
+      </button>
+      <button
+        onClick={() => {
+          updateRequest(
+            "rules",
+            Object.values(LimitOperationType).map((operation_type) => ({
+              display_priority: 1,
+              measures: ["VERBOTEN"],
+              operation_type,
+              threshold: Amounts.stringify({
+                currency: config.config.currency,
+                fraction: 0,
+                value: 12000,
+              }),
+              timeframe: Duration.toTalerProtocolDuration(
+                operation_type === LimitOperationType.transaction ||
+                  operation_type === LimitOperationType.balance
+                  ? Duration.getForever()
+                  : Duration.fromSpec({
+                      months: 1,
+                    }),
+              ),
+            })),
+          );
+        }}
+        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 disabled:bg-gray-600"
+      >
+        <i18n.Translate>Premium</i18n.Translate>
+      </button>
       <RulesInfo
         rules={request.rules ?? []}
-        // onEdit={(r, idx) => {
-        //   const nr = !request.rules ? [] : [...request.rules];
-        //   nr.splice(idx, 1);
-        //   updateRequest("rules", nr);
-        // }}
         onRemove={(r, idx) => {
           const nr = !request.rules ? [] : [...request.rules];
           nr.splice(idx, 1);
@@ -134,7 +211,8 @@ type FormType = {
   threshold: AmountJson;
   timeframe: Duration;
   exposed: boolean;
-  paths: { steps: Array<string> }[];
+  measures: string[];
+  all: boolean;
 };
 
 function labelForOperationType(
@@ -189,16 +267,36 @@ const formDesign = (
     },
     {
       id: "timeframe" as UIHandlerId,
-      type: "duration",
-      required: true,
+      type: "durationText",
+      // required: true,
+      placeholder: "1Y 2M 3D 4h 5m 6s",
       label: i18n.str`Timeframe`,
+      help: `Use YMDhms next to a number as a unit for Year, Month, Day, hour, 
minute and seconds.`,
     },
     {
       id: "exposed" as UIHandlerId,
       type: "toggle",
       label: i18n.str`Exposed`,
-      help: i18n.str`Is the customer aware of this limit?`,
+      help: i18n.str`Is this limit comunicated to the customer?`,
+    },
+    {
+      type: "selectMultiple",
+      unique: true,
+      choices: mi.map((m) => {
+        return {
+          value: m.id,
+          label: m.id,
+        };
+      }),
+      id: "measures" as UIHandlerId,
+      label: i18n.str`Esclation measure`,
+      help: i18n.str`Measures that the customer will need to satisfy to apply 
for a new threshold.`,
+    },
+    {
+      id: "all" as UIHandlerId,
+      type: "toggle",
+      label: i18n.str`All measures`,
+      help: i18n.str`Hint the customer that all measure should be completed`,
     },
-    measureArrayField(i18n, mi),
   ],
 });
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx 
b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx
index 37fa9b493..d598734df 100644
--- a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx
+++ b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx
@@ -1,23 +1,208 @@
 import {
-  FormDesign,
-  FormUI,
-  InternationalizationAPI,
-  onComponentUnload,
-  UIHandlerId,
-  useForm,
+  AbsoluteTime,
+  AmlDecisionRequest,
+  assertUnreachable,
+  HttpStatusCode,
+  TalerError,
+} from "@gnu-taler/taler-util";
+import {
+  Attention,
+  Button,
+  LocalNotificationBanner,
+  useExchangeApiContext,
+  useLocalNotificationHandler,
   useTranslationContext,
 } from "@gnu-taler/web-util/browser";
-import { h, VNode } from "preact";
+import { Fragment, h, VNode } from "preact";
 import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
+import { useServerMeasures } from "../../hooks/server-info.js";
+import {
+  computeAvailableMesaures,
+  ShowDecisionLimitInfo,
+} from "../CaseDetails.js";
+import { CurrentMeasureTable } from "../MeasuresTable.js";
+import { useOfficer } from "../../hooks/officer.js";
 
 /**
  * Mark for further investigation and explain decision
  * @param param0
  * @returns
  */
-export function Summary({}: {}): VNode {
+export function Summary({ account }: { account?: string }): VNode {
   const { i18n } = useTranslationContext();
-  const [request] = useCurrentDecisionRequest();
+  const [decision, _, updateDecision] = useCurrentDecisionRequest();
+  const measures = useServerMeasures();
+  const [notification, withErrorHandler] = useLocalNotificationHandler();
+  const officer = useOfficer();
+  const session = officer.state === "ready" ? officer.account : undefined;
+
+  const allMeasures =
+    !measures || measures instanceof TalerError || measures.type === "fail"
+      ? []
+      : Object.values(computeAvailableMesaures(measures.body, {}));
+
+  const d = decision.new_measures === undefined ? [] : decision.new_measures;
+  const activeMeasureInfo = allMeasures.filter((m) => d.indexOf(m.name) !== 
-1);
+
+  const { lib } = useExchangeApiContext();
+
+  const INVALID_RULES = !decision.deadline || !decision.rules;
+  const INVALID_MEASURES = decision.new_measures === undefined;
+  const INVALID_PROPERTIES = decision.properties === undefined;
+  const INVALID_EVENTS = decision.inhibit_events === undefined;
+  const INVALID_JUSTIFICATION =
+    decision.justification === undefined || !decision.justification;
+  const INVALID_ACCOUNT = !account;
+  const CANT_SUBMIT =
+    INVALID_ACCOUNT ||
+    INVALID_EVENTS ||
+    INVALID_JUSTIFICATION ||
+    INVALID_MEASURES ||
+    INVALID_PROPERTIES ||
+    INVALID_RULES;
+
+  const submitHandler =
+    CANT_SUBMIT || !session
+      ? undefined
+      : withErrorHandler(
+          () => {
+            const request: Omit<AmlDecisionRequest, "officer_sig"> = {
+              h_payto: account,
+              decision_time: AbsoluteTime.toProtocolTimestamp(
+                AbsoluteTime.now(),
+              ),
+              justification: decision.justification!,
+              keep_investigating: decision.keep_investigating,
+              new_rules: {
+                expiration_time: AbsoluteTime.toProtocolTimestamp(
+                  decision.deadline!,
+                ),
+                rules: decision.rules!,
+                successor_measure: decision.onExpire_measures!.join(" "),
+                custom_measures: {}, // TODO: compute custom measures
+              },
+              properties: decision.properties!, // TODO: compute properites
+              new_measures: decision.new_measures!.join(" "),
+            };
+            return lib.exchange.makeAmlDesicion(session, request);
+          },
+          () => {
+            updateDecision({
+              custom_events: undefined,
+              custom_properties: undefined,
+              deadline: undefined,
+              inhibit_events: undefined,
+              justification: undefined,
+              keep_investigating: false,
+              new_measures: undefined,
+              onExpire_measures: undefined,
+              properties: undefined,
+              rules: undefined,
+            });
+          },
+          (fail) => {
+            switch (fail.case) {
+              case HttpStatusCode.Forbidden:
+                if (session) {
+                  return i18n.str`Wrong credentials for "${session}"`;
+                } else {
+                  return i18n.str`Wrong credentials.`;
+                }
+              case HttpStatusCode.NotFound:
+                return i18n.str`The account was not found`;
+              case HttpStatusCode.Conflict:
+                return i18n.str`Officer disabled or more recent decision was 
already submitted.`;
+              default:
+                assertUnreachable(fail);
+            }
+          },
+        );
+
+  return (
+    <Fragment>
+      <LocalNotificationBanner notification={notification} />
+
+      {INVALID_RULES ? (
+        <Fragment>
+          {!decision.deadline && (
+            <Attention type="danger" title={i18n.str`Missing deadline`}>
+              <i18n.Translate>
+                Deadline should specify when this rules ends and what is the
+                next measures to apply after expiration.
+              </i18n.Translate>
+            </Attention>
+          )}
+          {!decision.rules && (
+            <Attention type="danger" title={i18n.str`Missing rules`}>
+              <i18n.Translate>
+                Can't make a decision without rules.
+              </i18n.Translate>
+            </Attention>
+          )}
+        </Fragment>
+      ) : (
+        <div>
+          <h2 class="mt-4 mb-2">
+            <i18n.Translate>New rules</i18n.Translate>
+          </h2>
+          <ShowDecisionLimitInfo
+            fixed
+            since={AbsoluteTime.now()}
+            until={decision.deadline}
+            rules={decision.rules}
+            startOpen
+          />
+        </div>
+      )}
+      {INVALID_MEASURES ? (
+        <Attention type="danger" title={i18n.str`Missing active measure`}>
+          <i18n.Translate>
+            You should specify in the measure section.
+          </i18n.Translate>
+        </Attention>
+      ) : decision.new_measures.length === 0 ? (
+        <Attention type="info" title={i18n.str`No customer action required.`}>
+          <i18n.Translate>No active measure has been selected.</i18n.Translate>
+        </Attention>
+      ) : (
+        <CurrentMeasureTable list={activeMeasureInfo} />
+      )}
+      {INVALID_PROPERTIES ? (
+        <Attention type="danger" title={i18n.str`Missing properties`}>
+          <i18n.Translate>
+            You should specify in the properties section.
+          </i18n.Translate>
+        </Attention>
+      ) : (
+        <div />
+      )}
+      {INVALID_EVENTS ? (
+        <Attention type="danger" title={i18n.str`Missing events`}>
+          <i18n.Translate>
+            You should specify in the properties section.
+          </i18n.Translate>
+        </Attention>
+      ) : (
+        <div />
+      )}
+      {INVALID_JUSTIFICATION ? (
+        <Attention type="danger" title={i18n.str`Missing justification`}>
+          <i18n.Translate>
+            You should specify in the properties section.
+          </i18n.Translate>
+        </Attention>
+      ) : (
+        <div />
+      )}
 
-  return <div>summary</div>;
+      <Button
+        type="submit"
+        handler={submitHandler}
+        disabled={!submitHandler}
+        class="disabled:opacity-50 disabled:cursor-default 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>Send decision</i18n.Translate>
+      </Button>
+    </Fragment>
+  );
 }
diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts
index 93e34bbaf..81774455c 100644
--- a/packages/taler-util/src/time.ts
+++ b/packages/taler-util/src/time.ts
@@ -304,25 +304,26 @@ export namespace Duration {
         minutes: number;
         hours: number;
         days: number;
+        month: number;
+        years: number;
       }
     | undefined {
     if (d_ms === "forever") return undefined;
-    let time = d_ms;
-    const millis = d_ms % SECONDS;
-    time -= millis;
-    const s = time % MINUTES;
-    time -= s;
-    const m = time % HOURS;
-    time -= m;
-    const h = time % DAYS;
-    time -= h;
-    const d = time;
+    const ms = d_ms > 0 ? d_ms : 0;
+    const Y_rest = ms % YEARS;
+    const M_rest = Y_rest % MONTHS;
+    const D_rest = M_rest % DAYS;
+    const h_rest = D_rest % HOURS;
+    const m_rest = h_rest % MINUTES;
+    const millis = m_rest % SECONDS;
 
     return {
-      seconds: s / SECONDS,
-      minutes: m / MINUTES,
-      hours: h / HOURS,
-      days: d / DAYS,
+      years: (ms - Y_rest) / YEARS,
+      month: (Y_rest - M_rest) / MONTHS,
+      days: (M_rest - D_rest) / DAYS,
+      hours: (D_rest - h_rest) / HOURS,
+      minutes: (h_rest - m_rest) / MINUTES,
+      seconds: (m_rest - millis) / SECONDS,
     };
   }
 
diff --git a/packages/web-util/src/components/index.ts 
b/packages/web-util/src/components/index.ts
index 944bed269..17d89ad1f 100644
--- a/packages/web-util/src/components/index.ts
+++ b/packages/web-util/src/components/index.ts
@@ -1,5 +1,5 @@
 export * as utils from "./utils.js";
-export { onComponentUnload } from "./utils.js";
+export { onComponentUnload, preconnectAs, Preconnect } from "./utils.js";
 export * from "./Attention.js";
 export * from "./CopyButton.js";
 export * from "./ErrorLoading.js";
diff --git a/packages/web-util/src/components/utils.ts 
b/packages/web-util/src/components/utils.ts
index a9871ce85..9cbc1bbba 100644
--- a/packages/web-util/src/components/utils.ts
+++ b/packages/web-util/src/components/utils.ts
@@ -83,6 +83,30 @@ export function onComponentUnload(callback: () => void) {
   }, []);
 }
 
+const ownerDocument = typeof document === "undefined" ? null : document;
+const preconnectsSet: Set<string> = new Set();
+
+export type Preconnect = {
+  rel: "preconnect" | "dns-prefetch";
+  href: string;
+  crossOrigin: string;
+};
+
+export function preconnectAs(pre: Preconnect[]) {
+  if (ownerDocument) {
+    pre.forEach(({ rel, href, crossOrigin }) => {
+      const key = `${rel}${href}${crossOrigin}`;
+      if (preconnectsSet.has(key)) return;
+      preconnectsSet.add(key);
+      const instance = ownerDocument.createElement("link");
+      instance.setAttribute("rel", rel);
+      instance.setAttribute("crossOrigin", crossOrigin);
+      instance.setAttribute("href", href);
+      ownerDocument.head.appendChild(instance);
+    });
+  }
+}
+
 /**
  *
  * @param obj VNode
diff --git a/packages/web-util/src/forms/field-types.ts 
b/packages/web-util/src/forms/field-types.ts
index 34696b430..e65f44b1c 100644
--- a/packages/web-util/src/forms/field-types.ts
+++ b/packages/web-util/src/forms/field-types.ts
@@ -17,6 +17,7 @@ import { InputTextArea } from "./fields/InputTextArea.js";
 import { InputToggle } from "./fields/InputToggle.js";
 import { Group } from "./Group.js";
 import { HtmlIframe } from "./HtmlIframe.js";
+import { InputDurationText } from "./fields/InputDurationText.js";
 /**
  * Constrain the type with the ui props
  */
@@ -39,6 +40,7 @@ type FieldType<T extends object = any, K extends keyof T = 
any> = {
   toggle: Parameters<typeof InputToggle<T, K>>[0];
   amount: Parameters<typeof InputAmount<T, K>>[0];
   duration: Parameters<typeof InputDuration<T, K>>[0];
+  durationText: Parameters<typeof InputDurationText<T, K>>[0];
 };
 
 /**
@@ -77,6 +79,10 @@ export type UIFormField =
   | {
       type: "duration";
       properties: FieldType["duration"];
+    }
+  | {
+      type: "durationText";
+      properties: FieldType["durationText"];
     };
 
 export type FieldComponentFunction<key extends keyof FieldType> = (
@@ -118,4 +124,5 @@ export const UIFormConfiguration: UIFormFieldMap = {
   //@ts-ignore
   amount: InputAmount,
   duration: InputDuration,
+  durationText: InputDurationText,
 };
diff --git a/packages/web-util/src/forms/fields/InputDuration.stories.tsx 
b/packages/web-util/src/forms/fields/InputDuration.stories.tsx
index 8c0983287..d73e5c535 100644
--- a/packages/web-util/src/forms/fields/InputDuration.stories.tsx
+++ b/packages/web-util/src/forms/fields/InputDuration.stories.tsx
@@ -33,10 +33,10 @@ type TargetObject = {
 };
 const initial: TargetObject = {
   time: Duration.fromSpec({
-    days: 1,
-    hours: 2,
-    minutes: 3,
-    seconds: 4,
+    days: 29,
+    hours: 23,
+    minutes: 59,
+    seconds: 59,
   }),
 };
 
diff --git a/packages/web-util/src/forms/fields/InputDuration.tsx 
b/packages/web-util/src/forms/fields/InputDuration.tsx
index 9c368bdf3..08364694b 100644
--- a/packages/web-util/src/forms/fields/InputDuration.tsx
+++ b/packages/web-util/src/forms/fields/InputDuration.tsx
@@ -4,7 +4,7 @@ import { UIFormProps } from "../FormProvider.js";
 import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
 import { InputWrapper } from "./InputLine.js";
 import { Duration } from "@gnu-taler/taler-util";
-import { useEffect, useState } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
 
 export function InputDuration<T extends object, K extends keyof T>(
   props: UIFormProps<T, K>,
@@ -14,26 +14,75 @@ export function InputDuration<T extends object, K extends 
keyof T>(
   const { value, onChange, state, error } =
     props.handler ?? noHandlerPropsAndNoContextForField(props.name);
 
-  const sd = !value ? undefined : Duration.toSpec(value as Duration);
-  const [days, setDays] = useState(sd?.days ?? 0);
-  const [hours, setHours] = useState(sd?.hours ?? 0);
-  const [minutes, setMinutes] = useState(sd?.minutes ?? 0);
-  const [seconds, setSeconds] = useState(sd?.seconds ?? 0);
+  const specDuration = !value ? undefined : Duration.toSpec(value as Duration);
+  // const [seconds, setSeconds] = useState(sd?.seconds ?? 0);
+  // const [hours, setHours] = useState(sd?.hours ?? 0);
+  // const [minutes, setMinutes] = useState(sd?.minutes ?? 0);
+  // const [days, setDays] = useState(sd?.days ?? 0);
+  // const [months, setMonths] = useState(sd?.month ?? 0);
+  // const [years, setYears] = useState(sd?.years ?? 0);
 
-  useEffect(() => {
-    onChange(
-      Duration.fromSpec({
-        days,
-        hours,
-        minutes,
-        seconds,
-      }),
-    );
-  }, [days, hours, minutes, seconds]);
+  const secondsRef = useRef<HTMLInputElement>(null);
+  const hoursRef = useRef<HTMLInputElement>(null);
+  const minutesRef = useRef<HTMLInputElement>(null);
+  const daysRef = useRef<HTMLInputElement>(null);
+  const monthsRef = useRef<HTMLInputElement>(null);
+  const yearsRef = useRef<HTMLInputElement>(null);
+
+  // useEffect(() => {
+  //   onChange(
+  //     Duration.fromSpec({
+  //       days,
+  //       hours,
+  //       minutes,
+  //       seconds,
+  //       months,
+  //       years,
+  //     }),
+  //   );
+  // }, [days, hours, minutes, seconds, months, years]);
   const fromString: (s: string) => any =
     converter?.fromStringUI ?? defaultFromString;
   const toString: (s: any) => string = converter?.toStringUI ?? 
defaultToString;
 
+  const strSeconds = toString(specDuration?.seconds ?? 0) ?? "";
+  const strHours = toString(specDuration?.hours ?? 0) ?? "";
+  const strMinutes = toString(specDuration?.minutes ?? 0) ?? "";
+  const strDays = toString(specDuration?.days ?? 0) ?? "";
+  const strMonths = toString(specDuration?.month ?? 0) ?? "";
+  const strYears = toString(specDuration?.years ?? 0) ?? "";
+
+  useEffect(() => {
+    if (!secondsRef.current) return;
+    if (secondsRef.current === document.activeElement) return;
+    secondsRef.current.value = strSeconds;
+  }, [strSeconds]);
+  useEffect(() => {
+    if (!minutesRef.current) return;
+    if (minutesRef.current === document.activeElement) return;
+    minutesRef.current.value = strMinutes;
+  }, [strMinutes]);
+  useEffect(() => {
+    if (!hoursRef.current) return;
+    if (hoursRef.current === document.activeElement) return;
+    hoursRef.current.value = strHours;
+  }, [strHours]);
+  useEffect(() => {
+    if (!daysRef.current) return;
+    if (daysRef.current === document.activeElement) return;
+    daysRef.current.value = strDays;
+  }, [strDays]);
+  useEffect(() => {
+    if (!monthsRef.current) return;
+    if (monthsRef.current === document.activeElement) return;
+    monthsRef.current.value = strMonths;
+  }, [strMonths]);
+  useEffect(() => {
+    if (!yearsRef.current) return;
+    if (yearsRef.current === document.activeElement) return;
+    yearsRef.current.value = strYears;
+  }, [strYears]);
+
   if (state.hidden) return <div />;
 
   let clazz =
@@ -86,6 +135,60 @@ export function InputDuration<T extends object, K extends 
keyof T>(
       error={showError ? error : undefined}
     >
       <div class="flex flex-col gap-1">
+        <div class="flex">
+          <span class="ml-2 inline-flex items-center rounded-l-md border 
border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
+            <i18n.Translate>years</i18n.Translate>
+          </span>
+          <input
+            name={String(name)}
+            type="number"
+            onChange={(e) => {
+              onChange(
+                Duration.fromSpec({
+                  ...specDuration,
+                  years: fromString(e.currentTarget.value),
+                }),
+              );
+            }}
+            placeholder={placeholder ? placeholder : undefined}
+            ref={yearsRef}
+            // value={toString(sd?.years) ?? ""}
+            // onBlur={() => {
+            //   onChange(fromString(value as any));
+            // }}
+            // defaultValue={toString(value)}
+            disabled={disabled ?? false}
+            aria-invalid={showError}
+            // aria-describedby="email-error"
+            class={clazz}
+          />
+          <span class="ml-2 inline-flex items-center rounded-l-md border 
border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
+            <i18n.Translate>months</i18n.Translate>
+          </span>
+          <input
+            name={String(name)}
+            type="number"
+            ref={monthsRef}
+            onChange={(e) => {
+              onChange(
+                Duration.fromSpec({
+                  ...specDuration,
+                  months: fromString(e.currentTarget.value),
+                }),
+              );
+            }}
+            placeholder={placeholder ? placeholder : undefined}
+            // value={toString(specDuration?.month) ?? ""}
+            // onBlur={() => {
+            //   onChange(fromString(value as any));
+            // }}
+            // defaultValue={toString(value)}
+            disabled={disabled ?? false}
+            aria-invalid={showError}
+            // aria-describedby="email-error"
+            class={clazz}
+          />
+        </div>
         <div class="flex">
           <span class="ml-2 inline-flex items-center rounded-l-md border 
border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
             <i18n.Translate>days</i18n.Translate>
@@ -93,11 +196,17 @@ export function InputDuration<T extends object, K extends 
keyof T>(
           <input
             name={String(name)}
             type="number"
+            ref={daysRef}
             onChange={(e) => {
-              setDays(fromString(e.currentTarget.value));
+              onChange(
+                Duration.fromSpec({
+                  ...specDuration,
+                  days: fromString(e.currentTarget.value),
+                }),
+              );
             }}
             placeholder={placeholder ? placeholder : undefined}
-            value={toString(sd?.days) ?? ""}
+            // value={toString(specDuration?.days) ?? ""}
             // onBlur={() => {
             //   onChange(fromString(value as any));
             // }}
@@ -113,11 +222,17 @@ export function InputDuration<T extends object, K extends 
keyof T>(
           <input
             name={String(name)}
             type="number"
+            ref={hoursRef}
             onChange={(e) => {
-              setHours(fromString(e.currentTarget.value));
+              onChange(
+                Duration.fromSpec({
+                  ...specDuration,
+                  hours: fromString(e.currentTarget.value),
+                }),
+              );
             }}
             placeholder={placeholder ? placeholder : undefined}
-            value={toString(sd?.hours) ?? ""}
+            // value={toString(specDuration?.hours) ?? ""}
             // onBlur={() => {
             //   onChange(fromString(value as any));
             // }}
@@ -135,11 +250,17 @@ export function InputDuration<T extends object, K extends 
keyof T>(
           <input
             name={String(name)}
             type="number"
+            ref={minutesRef}
             onChange={(e) => {
-              setMinutes(fromString(e.currentTarget.value));
+              onChange(
+                Duration.fromSpec({
+                  ...specDuration,
+                  minutes: fromString(e.currentTarget.value),
+                }),
+              );
             }}
             placeholder={placeholder ? placeholder : undefined}
-            value={toString(sd?.minutes) ?? ""}
+            // value={toString(specDuration?.minutes) ?? ""}
             // onBlur={() => {
             //   onChange(fromString(value as any));
             // }}
@@ -155,11 +276,18 @@ export function InputDuration<T extends object, K extends 
keyof T>(
           <input
             name={String(name)}
             type="number"
+            ref={secondsRef}
             onChange={(e) => {
-              setSeconds(fromString(e.currentTarget.value));
+              // setSeconds(fromString(e.currentTarget.value));
+              onChange(
+                Duration.fromSpec({
+                  ...specDuration,
+                  seconds: fromString(e.currentTarget.value),
+                }),
+              );
             }}
             placeholder={placeholder ? placeholder : undefined}
-            value={toString(sd?.seconds) ?? ""}
+            // value={toString(specDuration?.seconds) ?? ""}
             // onBlur={() => {
             //   onChange(fromString(value as any));
             // }}
diff --git a/packages/web-util/src/forms/fields/InputInteger.stories.tsx 
b/packages/web-util/src/forms/fields/InputDurationText.stories.tsx
similarity index 83%
copy from packages/web-util/src/forms/fields/InputInteger.stories.tsx
copy to packages/web-util/src/forms/fields/InputDurationText.stories.tsx
index 0a2bcaca0..eb4ea1dfc 100644
--- a/packages/web-util/src/forms/fields/InputInteger.stories.tsx
+++ b/packages/web-util/src/forms/fields/InputDurationText.stories.tsx
@@ -19,20 +19,20 @@
  * @author Sebastian Javier Marchano (sebasjm)
  */
 
-import { TranslatedString } from "@gnu-taler/taler-util";
+import { Duration, TranslatedString } from "@gnu-taler/taler-util";
 import * as tests from "../../tests/hook.js";
 import { DefaultForm as TestedComponent } from "../forms-ui.js";
 import { FormDesign, UIHandlerId } from "../forms-types.js";
 
 export default {
-  title: "Input Integer",
+  title: "Input Duration Text",
 };
 
 type TargetObject = {
-  age: number;
+  age: Duration;
 };
 const initial: TargetObject = {
-  age: 5,
+  age: { d_ms: 10000000 },
 };
 
 const design: FormDesign = {
@@ -42,7 +42,7 @@ const design: FormDesign = {
       title: "this is a simple form" as TranslatedString,
       fields: [
         {
-          type: "integer",
+          type: "durationText",
           label: "Age" as TranslatedString,
           id: "age" as UIHandlerId,
           tooltip: "just numbers" as TranslatedString,
@@ -56,3 +56,8 @@ export const SimpleComment = 
tests.createExample(TestedComponent, {
   initial,
   design,
 });
+
+export const Empty = tests.createExample(TestedComponent, {
+  initial: { age: undefined },
+  design,
+});
diff --git a/packages/web-util/src/forms/fields/InputDurationText.tsx 
b/packages/web-util/src/forms/fields/InputDurationText.tsx
new file mode 100644
index 000000000..3a4316117
--- /dev/null
+++ b/packages/web-util/src/forms/fields/InputDurationText.tsx
@@ -0,0 +1,105 @@
+import { VNode, h } from "preact";
+import { InputLine } from "./InputLine.js";
+import { UIFormProps } from "../FormProvider.js";
+import { Duration } from "@gnu-taler/taler-util";
+
+const PATTERN = /^(?<value>[0-9]+)(?<unit>[smhDMY])$/;
+const UNIT_GROUP = "unit";
+const VALUE_GROUP = "value";
+
+type DurationUnit = "s" | "m" | "h" | "D" | "M" | "Y";
+type DurationSpec = Parameters<typeof Duration.fromSpec>[0];
+
+type DurationValue = {
+  unit: DurationUnit;
+  value: number;
+};
+
+function updateSpec(spec: DurationSpec, value: DurationValue): void {
+  switch (value.unit) {
+    case "s": {
+      spec.seconds = value.value;
+      break;
+    }
+    case "m": {
+      spec.minutes = value.value;
+      break;
+    }
+    case "h": {
+      spec.hours = value.value;
+      break;
+    }
+    case "D": {
+      spec.days = value.value;
+      break;
+    }
+    case "M": {
+      spec.months = value.value;
+      break;
+    }
+    case "Y": {
+      spec.years = value.value;
+      break;
+    }
+  }
+}
+
+function parseDurationValue(str: string): DurationValue | undefined {
+  const r = PATTERN.exec(str);
+  if (!r) return undefined;
+  const value = Number.parseInt(r.groups![VALUE_GROUP], 10);
+  const unit = r.groups![UNIT_GROUP] as DurationUnit;
+  return { value, unit };
+}
+
+export function InputDurationText<T extends object, K extends keyof T>(
+  props: UIFormProps<T, K>,
+): VNode {
+  return (
+    // <div>s</div>
+    <InputLine
+      type="text"
+      {...props}
+      converter={{
+        //@ts-ignore
+        fromStringUI: (v): Duration => {
+          if (!v) return Duration.getForever();
+          const spec = v.split(" ").reduce((prev, cur) => {
+            const v = parseDurationValue(cur);
+            if (v) {
+              updateSpec(prev, v);
+            }
+            return prev;
+          }, {} as DurationSpec);
+          return Duration.fromSpec(spec);
+        },
+        //@ts-ignore
+        toStringUI: (v?: Duration): string => {
+          if (v === undefined) return "";
+          // return v! as any;
+          const spec = Duration.toSpec(v);
+          let result = "";
+          if (spec?.years) {
+            result += `${spec.years}Y `;
+          }
+          if (spec?.month) {
+            result += `${spec.month}M `;
+          }
+          if (spec?.days) {
+            result += `${spec.days}D `;
+          }
+          if (spec?.hours) {
+            result += `${spec.hours}h `;
+          }
+          if (spec?.minutes) {
+            result += `${spec.minutes}m `;
+          }
+          if (spec?.seconds) {
+            result += `${spec.seconds}s `;
+          }
+          return result.trimEnd();
+        },
+      }}
+    />
+  );
+}
diff --git a/packages/web-util/src/forms/fields/InputInteger.stories.tsx 
b/packages/web-util/src/forms/fields/InputInteger.stories.tsx
index 0a2bcaca0..b20048f70 100644
--- a/packages/web-util/src/forms/fields/InputInteger.stories.tsx
+++ b/packages/web-util/src/forms/fields/InputInteger.stories.tsx
@@ -52,7 +52,34 @@ const design: FormDesign = {
   ],
 };
 
+const design2: FormDesign = {
+  type: "double-column",
+  sections: [
+    {
+      title: "this is a simple form" as TranslatedString,
+      fields: [
+        {
+          type: "integer",
+          label: "Age" as TranslatedString,
+          id: "age" as UIHandlerId,
+          tooltip: "just numbers" as TranslatedString,
+        },
+        {
+          type: "integer",
+          label: "Age" as TranslatedString,
+          id: "age" as UIHandlerId,
+          tooltip: "just numbers" as TranslatedString,
+        },
+      ],
+    },
+  ],
+};
 export const SimpleComment = tests.createExample(TestedComponent, {
   initial,
   design,
 });
+
+export const DoubleInput = tests.createExample(TestedComponent, {
+  initial,
+  design: design2,
+});
diff --git a/packages/web-util/src/forms/fields/InputLine.tsx 
b/packages/web-util/src/forms/fields/InputLine.tsx
index bbbc871e0..e38c25af3 100644
--- a/packages/web-util/src/forms/fields/InputLine.tsx
+++ b/packages/web-util/src/forms/fields/InputLine.tsx
@@ -2,6 +2,7 @@ import { TranslatedString } from "@gnu-taler/taler-util";
 import { ComponentChildren, Fragment, VNode, h } from "preact";
 import { Addon, UIFormProps } from "../FormProvider.js";
 import { noHandlerPropsAndNoContextForField } from "./InputArray.js";
+import { useEffect, useRef } from "preact/hooks";
 
 //@ts-ignore
 const TooltipIcon = (
@@ -160,6 +161,7 @@ export function InputLine<T extends object, K extends keyof 
T>(
   props: { type: InputType } & UIFormProps<T, K>,
 ): VNode {
   const { name, placeholder, before, after, converter, type, disabled } = 
props;
+  const input = useRef<HTMLTextAreaElement & HTMLInputElement>(null);
 
   const { value, onChange, state, error } =
     props.handler ?? noHandlerPropsAndNoContextForField(props.name);
@@ -168,6 +170,12 @@ export function InputLine<T extends object, K extends 
keyof T>(
     converter?.fromStringUI ?? defaultFromString;
   const toString: (s: any) => string = converter?.toStringUI ?? 
defaultToString;
 
+  useEffect(() => {
+    if (!input.current) return;
+    if (input.current === document.activeElement) return;
+    input.current.value = !value ? "" : toString(value);
+  }, [value]);
+
   if (state.hidden) return <div />;
 
   let clazz =
@@ -223,12 +231,13 @@ export function InputLine<T extends object, K extends 
keyof T>(
       >
         <textarea
           rows={4}
+          ref={input}
           name={String(name)}
           onChange={(e) => {
             onChange(fromString(e.currentTarget.value));
           }}
           placeholder={placeholder ? placeholder : undefined}
-          value={toString(value) ?? ""}
+          // value={toString(value) ?? ""}
           // defaultValue={toString(value)}
           disabled={disabled ?? false}
           aria-invalid={showError}
@@ -248,16 +257,17 @@ export function InputLine<T extends object, K extends 
keyof T>(
     >
       <input
         name={String(name)}
+        ref={input}
         type={type}
         onChange={(e) => {
           onChange(fromString(e.currentTarget.value));
         }}
         placeholder={placeholder ? placeholder : undefined}
-        value={toString(value) ?? ""}
+        // value={toString(value) ?? ""}
         // onBlur={() => {
         //   onChange(fromString(value as any));
         // }}
-        // defaultValue={toString(value)}
+        defaultValue={toString(value)}
         disabled={disabled ?? false}
         aria-invalid={showError}
         // aria-describedby="email-error"
diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx 
b/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx
index 604efbe1f..719d2e906 100644
--- a/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx
+++ b/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx
@@ -52,6 +52,7 @@ const design: FormDesign = {
         {
           type: "selectMultiple",
           label: "allow duplicates" as TranslatedString,
+          help: "this is a help text" as TranslatedString,
           id: "pets" as UIHandlerId,
           placeholder: "search..." as TranslatedString,
           choices: [
diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.tsx 
b/packages/web-util/src/forms/fields/InputSelectMultiple.tsx
index d7e8f9032..10dc1f661 100644
--- a/packages/web-util/src/forms/fields/InputSelectMultiple.tsx
+++ b/packages/web-util/src/forms/fields/InputSelectMultiple.tsx
@@ -19,6 +19,7 @@ export function InputSelectMultiple<T extends object, K 
extends keyof T>(
     choices,
     placeholder,
     tooltip,
+    help,
     required,
     unique,
     max,
@@ -70,7 +71,9 @@ export function InputSelectMultiple<T extends object, K 
extends keyof T>(
               }}
               class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20"
             >
-              <span class="sr-only">Remove</span>
+              <span class="sr-only">
+                <i18n.Translate>Remove</i18n.Translate>
+              </span>
               <svg
                 viewBox="0 0 14 14"
                 class="h-5 w-5 stroke-gray-700/50 
group-hover:stroke-gray-700/75"
@@ -168,6 +171,11 @@ export function InputSelectMultiple<T extends object, K 
extends keyof T>(
           )}
         </div>
       )}
+      {help && (
+        <p class="mt-2 text-sm text-gray-500" id="email-description">
+          {help}
+        </p>
+      )}
     </div>
   );
 }
diff --git a/packages/web-util/src/forms/fields/InputText.stories.tsx 
b/packages/web-util/src/forms/fields/InputText.stories.tsx
index 1eb7275e7..d518bd9f2 100644
--- a/packages/web-util/src/forms/fields/InputText.stories.tsx
+++ b/packages/web-util/src/forms/fields/InputText.stories.tsx
@@ -62,3 +62,8 @@ export const SimpleComment = 
tests.createExample(TestedComponent, {
   initial,
   design,
 });
+
+export const Empty = tests.createExample(TestedComponent, {
+  initial: { comment: undefined },
+  design,
+});
diff --git a/packages/web-util/src/forms/fields/InputToggle.tsx 
b/packages/web-util/src/forms/fields/InputToggle.tsx
index ee20ec9f6..fcdd481ba 100644
--- a/packages/web-util/src/forms/fields/InputToggle.tsx
+++ b/packages/web-util/src/forms/fields/InputToggle.tsx
@@ -63,6 +63,11 @@ export function InputToggle<T extends object, K extends 
keyof T>(
           ></span>
         </button>
       </div>
+      {help && (
+        <p class="mt-2 text-sm text-gray-500" id="email-description">
+          {help}
+        </p>
+      )}
     </div>
   );
 }
diff --git a/packages/web-util/src/forms/forms-types.ts 
b/packages/web-util/src/forms/forms-types.ts
index 0da107e34..7509a7cc8 100644
--- a/packages/web-util/src/forms/forms-types.ts
+++ b/packages/web-util/src/forms/forms-types.ts
@@ -59,6 +59,7 @@ export type UIFormElementConfig =
   | UIFormFieldSecret
   | UIFormFieldSelectMultiple
   | UIFormFieldDuration
+  | UIFormFieldDurationText
   | UIFormFieldSelectOne
   | UIFormFieldText
   | UIFormFieldTextArea
@@ -151,6 +152,10 @@ type UIFormFieldDuration = {
   type: "duration";
 } & UIFormFieldBaseConfig;
 
+type UIFormFieldDurationText = {
+  type: "durationText";
+} & UIFormFieldBaseConfig;
+
 type UIFormFieldSelectOne = {
   type: "selectOne";
   choices: Array<SelectUiChoice>;
@@ -332,6 +337,11 @@ const codecForUiFormFieldDuration = (): 
Codec<UIFormFieldDuration> =>
     .property("type", codecForConstString("duration"))
     .build("UiFormFieldDuration");
 
+const codecForUiFormFieldDurationText = (): Codec<UIFormFieldDurationText> =>
+  codecForUIFormFieldBaseConfigTemplate<UIFormFieldDurationText>()
+    .property("type", codecForConstString("durationText"))
+    .build("UiFormFieldDuration");
+
 const codecForUiFormFieldSelectMultiple =
   (): Codec<UIFormFieldSelectMultiple> =>
     codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectMultiple>()
@@ -383,6 +393,7 @@ const codecForUiFormField = (): Codec<UIFormElementConfig> 
=>
     .alternative("secret", codecForUiFormFieldSecret())
     .alternative("selectMultiple", codecForUiFormFieldSelectMultiple())
     .alternative("duration", codecForUiFormFieldDuration())
+    .alternative("durationText", codecForUiFormFieldDurationText())
     .alternative("selectOne", codecForUiFormFieldSelectOne())
     .alternative("text", codecForUiFormFieldText())
     .alternative("textArea", codecForUiFormFieldTextArea())
diff --git a/packages/web-util/src/forms/forms-utils.ts 
b/packages/web-util/src/forms/forms-utils.ts
index dc7d7d22f..ebf349de0 100644
--- a/packages/web-util/src/forms/forms-utils.ts
+++ b/packages/web-util/src/forms/forms-utils.ts
@@ -258,6 +258,19 @@ export function convertFormConfigToUiField(
           },
         } as UIFormField;
       }
+      case "durationText": {
+        return {
+          type: "durationText",
+          properties: {
+            ...converBaseFieldsProps(i18n_, config),
+            ...converInputFieldsProps(
+              form,
+              config,
+              getConverterByFieldType(config.type, config),
+            ),
+          },
+        } as UIFormField;
+      }
       case "toggle": {
         return {
           type: "toggle",
diff --git a/packages/web-util/src/forms/index.stories.ts 
b/packages/web-util/src/forms/index.stories.ts
index 5b62c512a..823e71fac 100644
--- a/packages/web-util/src/forms/index.stories.ts
+++ b/packages/web-util/src/forms/index.stories.ts
@@ -12,3 +12,4 @@ export * as a12 from "./fields/InputTextArea.stories.js";
 export * as a13 from "./fields/InputToggle.stories.js";
 export * as a14 from "./fields/InputSecret.stories.js";
 export * as a15 from "./fields/InputDuration.stories.js";
+export * as a16 from "./fields/InputDurationText.stories.js";
diff --git a/packages/web-util/src/stories-utils.tsx 
b/packages/web-util/src/stories-utils.tsx
index d9c2406eb..2d3a334d5 100644
--- a/packages/web-util/src/stories-utils.tsx
+++ b/packages/web-util/src/stories-utils.tsx
@@ -192,6 +192,12 @@ function ExampleList({
                       borderRadius: 4,
                       marginBottom: 4,
                     }}
+                    onClick={(e) => {
+                      e.preventDefault();
+                      location.hash = `#${eId}`;
+                      onSelectStory(r, eId);
+                      history.pushState({}, "", `#${eId}`);
+                    }}
                   >
                     <a
                       href={`#${eId}`}
@@ -395,10 +401,10 @@ function folder(groupName: string, value: 
ComponentOrFolder): ComponentItem[] {
   try {
     title =
       typeof value === "object" &&
-        typeof value.default === "object" &&
-        value.default !== undefined &&
-        "title" in value.default &&
-        typeof value.default.title === "string"
+      typeof value.default === "object" &&
+      value.default !== undefined &&
+      "title" in value.default &&
+      typeof value.default.title === "string"
         ? value.default.title
         : undefined;
   } catch (e) {

-- 
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]