taler-typescript-core

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

commit 122d888b3103497bd38666d22dfa650003a67894
parent 977787d6a827174f86976c978c0c4c60907f79df
Author: Sebastian <sebasjm@gmail.com>
Date:   Sun, 26 Jan 2025 11:33:37 -0300

summary

Diffstat:
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 60+++++++++++++++++++++++++++++++++---------------------------
Mpackages/aml-backoffice-ui/src/pages/RulesInfo.tsx | 119++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mpackages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx | 17+++++++----------
Mpackages/aml-backoffice-ui/src/pages/decision/Events.tsx | 4++--
Mpackages/aml-backoffice-ui/src/pages/decision/Justification.tsx | 2++
Mpackages/aml-backoffice-ui/src/pages/decision/Measures.tsx | 3++-
Mpackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 9++++++---
Mpackages/aml-backoffice-ui/src/pages/decision/Summary.tsx | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
8 files changed, 356 insertions(+), 63 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -704,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 />; } @@ -731,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); @@ -1497,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/RulesInfo.tsx b/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx @@ -32,11 +32,43 @@ export function RulesInfo({ <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 sorted = [...rules].sort(sortKycRules); + 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; @@ -69,19 +101,58 @@ export function RulesInfo({ scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right" > - <i18n.Translate>Actions</i18n.Translate> + {/* <i18n.Translate>Actions</i18n.Translate> */} </th> )} </tr> </thead> - <tbody class="divide-y divide-gray-200"> + + <tbody id="thetable" class="divide-y divide-gray-200 bg-white "> {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} + <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-4 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right"> + <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)} @@ -103,11 +174,18 @@ export function RulesInfo({ </i18n.Translate> )} </td> - <td class=" relative whitespace-nowrap py-4 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right"> + <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-4 pl-3 pr-4 text-sm font-medium sm:pr-6"> + <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 @@ -151,6 +229,27 @@ export function RulesInfo({ })} </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> ); diff --git 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 @@ -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 @@ -79,6 +79,7 @@ const formDesign = ( { id: "justification" as UIHandlerId, type: "textArea", + required: true, label: i18n.str`Justification`, }, { @@ -122,6 +123,7 @@ const formDesign = ( 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 @@ -64,6 +64,7 @@ function formDesign( fields: [ { type: "selectMultiple", + unique: true, choices: mi.map((m) => { return { value: m.id, @@ -72,7 +73,7 @@ function formDesign( }), 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.`, + help: i18n.str`Measures that the customer will need to satisfy while the rules are active.`, }, ], }; diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx @@ -62,10 +62,12 @@ 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 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, @@ -266,7 +268,7 @@ const formDesign = ( { id: "timeframe" as UIHandlerId, 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.`, @@ -279,6 +281,7 @@ const formDesign = ( }, { type: "selectMultiple", + unique: true, choices: mi.map((m) => { return { value: m.id, diff --git 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> + ); }