taler-typescript-core

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

commit 977787d6a827174f86976c978c0c4c60907f79df
parent 8f329518e9ecd8ef14a85a6619b51b2e29aa160f
Author: Sebastian <sebasjm@gmail.com>
Date:   Sun, 26 Jan 2025 08:14:39 -0300

fix measure format, shortcut buttons

Diffstat:
Mpackages/aml-backoffice-ui/src/hooks/decision-request.ts | 11+++++++----
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 205+++----------------------------------------------------------------------------
Apackages/aml-backoffice-ui/src/pages/RulesInfo.tsx | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/aml-backoffice-ui/src/pages/decision/Justification.tsx | 41++++++++++++++++++++++-------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Measures.tsx | 62++++++++++----------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 121++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
6 files changed, 389 insertions(+), 287 deletions(-)

diff --git 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 @@ -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 @@ -797,169 +798,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 +932,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> ); } diff --git a/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx b/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx @@ -0,0 +1,236 @@ +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" + /> + ); + } + + const sorted = [...rules].sort(sortKycRules); + + 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 class="divide-y divide-gray-200"> + {sorted.map((r, idx) => { + 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=" relative whitespace-nowrap py-4 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-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> + ); +} + +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/Justification.tsx b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx @@ -1,4 +1,10 @@ import { + AbsoluteTime, + Duration, + MeasureInformation, + TalerError, +} from "@gnu-taler/taler-util"; +import { FormDesign, FormUI, InternationalizationAPI, @@ -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 = ( @@ -122,6 +114,17 @@ 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, + }; + }), + 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 @@ -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,22 +51,16 @@ 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", @@ -102,20 +70,10 @@ export function measureArrayField( 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 current 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 @@ -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,16 @@ 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), 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 +87,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 +98,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 +209,8 @@ type FormType = { threshold: AmountJson; timeframe: Duration; exposed: boolean; - paths: { steps: Array<string> }[]; + measures: string[]; + all: boolean; }; function labelForOperationType( @@ -189,16 +265,35 @@ const formDesign = ( }, { id: "timeframe" as UIHandlerId, - type: "duration", + 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", + 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), ], });