taler-typescript-core

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

commit 9b415001ff6332d97586bc3c8d64900f74c8587b
parent d9e4d8ec33936c27c6f101760b29de6ecfd1075e
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon, 21 Apr 2025 16:21:50 -0300

fix #9768 partially

Diffstat:
Mpackages/aml-backoffice-ui/src/pages/RulesInfo.tsx | 70+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mpackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 188+++++++++++++++++++++++++++++++++++++------------------------------------------
Mpackages/taler-util/src/time.ts | 29+++++++++++++++++++++++++++++
Mpackages/web-util/src/forms/fields/InputDurationText.stories.tsx | 1+
Mpackages/web-util/src/forms/fields/InputDurationText.tsx | 4++--
5 files changed, 178 insertions(+), 114 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx b/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx @@ -15,12 +15,18 @@ import { import { formatDuration, intervalToDuration } from "date-fns"; import { Fragment, h, VNode } from "preact"; +type KycRuleWithIdx = KycRule & { + idx: number; +}; + export function RulesInfo({ rules, onEdit, onRemove, + onNew, }: { rules: KycRule[]; + onNew?: () => void; onEdit?: (k: KycRule, idx: number) => void; onRemove?: (k: KycRule, idx: number) => void; }): VNode { @@ -29,14 +35,26 @@ export function RulesInfo({ 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> + <div> + <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> + {!onNew ? undefined : ( + <button + onClick={() => { + onNew(); + }} + 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>Add custom rule</i18n.Translate> + </button> + )} + </div> ); } @@ -51,7 +69,9 @@ export function RulesInfo({ [LimitOperationType.merge]: true, }; - const sorted = [...rules].sort((a, b) => { + const theRules = rules.map((r, idx): KycRuleWithIdx => ({ ...r, idx })); + + const sorted = theRules.sort((a, b) => { // to prevent iterate again we are using this sort function // to save present operation type OPERATION_TYPE_MISSING[a.operation_type] = false; @@ -98,14 +118,38 @@ export function RulesInfo({ scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right" > - {/* <i18n.Translate>Actions</i18n.Translate> */} + {!onNew ? undefined : ( + <button + onClick={() => { + onNew(); + }} + class="rounded-md w-fit border-0 p-1 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700 disabled:bg-gray-600" + > + <i18n.Translate> + <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="M12 4.5v15m7.5-7.5h-15" + /> + </svg> + </i18n.Translate> + </button> + )} </th> )} </tr> </thead> <tbody id="thetable" class="divide-y divide-gray-200 bg-white "> - {sorted.map((r, idx) => { + {sorted.map((r) => { 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"> @@ -184,7 +228,7 @@ export function RulesInfo({ {!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)}> + <button onClick={() => onEdit(r, r.idx)}> <svg xmlns="http://www.w3.org/2000/svg" fill="none" @@ -202,7 +246,7 @@ export function RulesInfo({ </button> )} {!onRemove ? undefined : ( - <button onClick={() => onRemove(r, idx)}> + <button onClick={() => onRemove(r, r.idx)}> <svg xmlns="http://www.w3.org/2000/svg" fill="none" diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx @@ -35,9 +35,8 @@ const DEFAULT_MEASURE_IF_NONE = ["VERBOTEN"]; export const DEFAULT_LIMITS_WHEN_NEW_ACCOUNT: LegitimizationRuleSet = { custom_measures: {}, expiration_time: AbsoluteTime.toProtocolTimestamp(AbsoluteTime.never()), - rules:[], -} - + rules: [], +}; /** * Defined new limits for the account @@ -114,6 +113,56 @@ export function Rules({ ); } +function AddNewRuleForm({ + onAdd, + onClose, + config, + measureList, +}: { + onAdd: (nr: RuleFormType) => void; + config: ExchangeVersionResponse; + measureList: MeasureListWithId; + onClose: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const ruleFormDesign = ruleFormDesignTemplate( + i18n, + config.currency, + measureList, + ); + + const ruleForm = useForm<RuleFormType>(ruleFormDesign, {}); + return ( + <Fragment> + <h2 class="mt-4 mb-2"> + <i18n.Translate>New rule form</i18n.Translate> + </h2> + <FormUI design={ruleFormDesign} model={ruleForm.model} /> + + <button + disabled={ruleForm.status.status === "fail"} + onClick={() => { + onAdd(ruleForm.status.result as RuleFormType); + onClose(); + }} + 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>Add</i18n.Translate> + </button> + <button + onClick={() => { + onClose(); + }} + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + </Fragment> + ); +} + +type MeasureListWithId = (MeasureInformation & { id: string })[]; + function UpdateRulesForm({ config, limits, @@ -127,18 +176,10 @@ function UpdateRulesForm({ const [request, updateRequestField, updateRequest] = useCurrentDecisionRequest(); const [showAddRuleForm, setShowAddRuleForm] = useState(false); - const measureList = !rootMeasures + const measureList: MeasureListWithId = !rootMeasures ? [] : Object.entries(rootMeasures).map(([id, mi]) => ({ id, ...mi })); - const ruleFormDesign = ruleFormDesignTemplate( - i18n, - config.currency, - measureList, - ); - - const ruleForm = useForm<RuleFormType>(ruleFormDesign, {}); - const expirationFormDesign = expirationFormDesignTemplate(i18n, measureList); const expirationForm = useForm<ExpirationFormType>(expirationFormDesign, { @@ -184,8 +225,20 @@ function UpdateRulesForm({ }); updateRequestField("rules", result); } + return ( <div> + {!showAddRuleForm ? undefined : ( + <AddNewRuleForm + measureList={measureList} + config={config} + onAdd={addNewRule} + onClose={() => { + setShowAddRuleForm(false); + }} + /> + )} + <h2 class="mt-4 mb-2"> <i18n.Translate>New rules</i18n.Translate> </h2> @@ -197,6 +250,9 @@ function UpdateRulesForm({ nr.splice(idx, 1); updateRequestField("rules", nr); }} + onNew={() => { + setShowAddRuleForm(true); + }} /> <button @@ -231,69 +287,32 @@ function UpdateRulesForm({ > <i18n.Translate>Premium</i18n.Translate> </button> + <h2 class="mt-4 mb-2"> + <i18n.Translate>On expiration behavior</i18n.Translate> + </h2> + <FormUI design={expirationFormDesign} model={expirationForm.model} /> <button onClick={() => { - setShowAddRuleForm(true); + expirationForm.model + .getHandlerForAttributeKey("measure") + .onChange(limits.successor_measure ?? ""); }} 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>Add new rule</i18n.Translate> + <i18n.Translate>Reset measure</i18n.Translate> + </button> + <button + onClick={() => { + const c = + expirationForm.model.getHandlerForAttributeKey("expiration"); + c.onChange( + AbsoluteTime.fromProtocolTimestamp(limits.expiration_time), + ); + }} + 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>Reset expiration</i18n.Translate> </button> - {!showAddRuleForm ? ( - <Fragment> - <h2 class="mt-4 mb-2"> - <i18n.Translate>On expiration behavior</i18n.Translate> - </h2> - <FormUI design={expirationFormDesign} model={expirationForm.model} /> - <button - onClick={() => { - expirationForm.model - .getHandlerForAttributeKey("measure") - .onChange(limits.successor_measure ?? ""); - }} - 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>Reset measure</i18n.Translate> - </button> - <button - onClick={() => { - const c = - expirationForm.model.getHandlerForAttributeKey("expiration"); - c.onChange( - AbsoluteTime.fromProtocolTimestamp(limits.expiration_time), - ); - }} - 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>Reset expiration</i18n.Translate> - </button> - </Fragment> - ) : ( - <Fragment> - <h2 class="mt-4 mb-2"> - <i18n.Translate>New rule form</i18n.Translate> - </h2> - <FormUI design={ruleFormDesign} model={ruleForm.model} /> - - <button - disabled={ruleForm.status.status === "fail"} - onClick={() => { - addNewRule(ruleForm.status.result as RuleFormType); - }} - 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>Add</i18n.Translate> - </button> - <button - onClick={() => { - setShowAddRuleForm(false); - }} - class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - </Fragment> - )} </div> ); } @@ -364,7 +383,7 @@ const ruleFormDesignTemplate = ( { id: "timeframe", type: "durationText", - // required: true, + 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.`, @@ -394,35 +413,6 @@ const ruleFormDesignTemplate = ( label: i18n.str`All measures`, help: i18n.str`Hint the customer that all measure should be completed`, }, - { - type: "choiceHorizontal", - label: i18n.str`Expiration`, - help: i18n.str`Predefined shortcuts`, - id: "expiration", - choices: [ - { - label: i18n.str`In a week`, - value: AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ days: 7 }), - ) as any, - }, - { - label: i18n.str`In a month`, - value: AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ months: 1 }), - ) as any, - }, - { - label: i18n.str`In a year`, - value: AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ years: 1 }), - ) as any, - }, - ], - }, ], }); const expirationFormDesignTemplate = ( diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts @@ -280,6 +280,13 @@ export namespace Duration { return Math.ceil(d.d_ms / 1000 / 60 / 60 / 24 / 365); } + /** + * FIXME: it should return undefined when the spec has no values + * @deprecated check fromSpecOrUndefined + * + * @param spec + * @returns + */ export function fromSpec(spec: { seconds?: number; minutes?: number; @@ -298,6 +305,28 @@ export namespace Duration { return { d_ms }; } + export function fromSpecOrUndefined(spec: { + seconds?: number; + minutes?: number; + hours?: number; + days?: number; + months?: number; + years?: number; + }): Duration | undefined { + if ( + spec.seconds == undefined && + spec.minutes == undefined && + spec.hours == undefined && + spec.days == undefined && + spec.months == undefined && + spec.years == undefined + ) { + return undefined; + } + + return Duration.fromSpec(spec) + } + export function toSpec({ d_ms }: Duration): | { seconds: number; diff --git a/packages/web-util/src/forms/fields/InputDurationText.stories.tsx b/packages/web-util/src/forms/fields/InputDurationText.stories.tsx @@ -45,6 +45,7 @@ const design: FormDesign = { type: "durationText", label: "Age" as TranslatedString, id: "age", + required: true, tooltip: "just numbers" as TranslatedString, }, ], diff --git a/packages/web-util/src/forms/fields/InputDurationText.tsx b/packages/web-util/src/forms/fields/InputDurationText.tsx @@ -59,7 +59,7 @@ export function InputDurationText(props: UIFormProps<string>): VNode { {...props} converter={{ //@ts-ignore - fromStringUI: (v): Duration => { + fromStringUI: (v): Duration | undefined => { if (!v) return Duration.getForever(); const spec = v.split(" ").reduce((prev, cur) => { const v = parseDurationValue(cur); @@ -68,7 +68,7 @@ export function InputDurationText(props: UIFormProps<string>): VNode { } return prev; }, {} as DurationSpec); - return Duration.fromSpec(spec); + return Duration.fromSpecOrUndefined(spec); }, //@ts-ignore toStringUI: (v?: Duration): string => {