taler-typescript-core

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

commit ccad582b4380ac4344cb231b95072743e3d20909
parent 122d888b3103497bd38666d22dfa650003a67894
Author: Sebastian <sebasjm@gmail.com>
Date:   Sun, 26 Jan 2025 19:08:14 -0300

custom measure local storage

Diffstat:
Mpackages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx | 6+++++-
Mpackages/aml-backoffice-ui/src/Routing.tsx | 30++++++++++++++++++++++++------
Apackages/aml-backoffice-ui/src/hooks/custom-measures.ts | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 157+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/aml-backoffice-ui/src/pages/Cases.tsx | 12++++++------
Mpackages/aml-backoffice-ui/src/pages/Dashboard.tsx | 12++++++------
Mpackages/aml-backoffice-ui/src/pages/Measures.tsx | 47+++++++++++++++--------------------------------
Mpackages/aml-backoffice-ui/src/pages/MeasuresTable.tsx | 273++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Measures.tsx | 7++++---
Mpackages/aml-backoffice-ui/src/pages/decision/Summary.tsx | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
11 files changed, 530 insertions(+), 245 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx @@ -259,7 +259,11 @@ function Navigation(): VNode { }, { route: privatePages.profile, Icon: PeopleIcon, label: i18n.str`Profile` }, showDebugInfo - ? { route: privatePages.measures, Icon: FormIcon, label: i18n.str`Forms` } + ? { + route: privatePages.measures, + Icon: FormIcon, + label: i18n.str`Measures`, + } : undefined, ]; const { path } = useNavigationContext(); diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx @@ -170,7 +170,13 @@ function PrivateRouting(): VNode { account={location.values.cid} onMove={(step) => { if (!step) { - navigateTo(privatePages.profile.url({})); + if (location.values.cid) { + navigateTo( + privatePages.caseDetails.url({ cid: location.values.cid }), + ); + } else { + navigateTo(privatePages.dashboard.url({})); + } } else { navigateTo( privatePages.decideWithStep.url({ @@ -189,7 +195,13 @@ function PrivateRouting(): VNode { account={location.values.cid} onMove={(step) => { if (!step) { - navigateTo(privatePages.profile.url({})); + if (location.values.cid) { + navigateTo( + privatePages.caseDetails.url({ cid: location.values.cid }), + ); + } else { + navigateTo(privatePages.dashboard.url({})); + } } else { navigateTo( privatePages.decideWithStep.url({ @@ -209,7 +221,13 @@ function PrivateRouting(): VNode { step={location.values.step as WizardSteps} onMove={(step) => { if (!step) { - navigateTo(privatePages.profile.url({})); + if (location.values.cid) { + navigateTo( + privatePages.caseDetails.url({ cid: location.values.cid }), + ); + } else { + navigateTo(privatePages.dashboard.url({})); + } } else { navigateTo( privatePages.decideWithStep.url({ @@ -255,11 +273,11 @@ function PrivateRouting(): VNode { } case "investigation": { return ( - <CasesUnderInvestigation caseByIdRoute={privatePages.caseDetails} /> + <CasesUnderInvestigation routeToCaseById={privatePages.caseDetails} /> ); } case "active": { - return <Cases caseByIdRoute={privatePages.caseDetails} />; + return <Cases routeToCaseById={privatePages.caseDetails} />; } case "search": { return <Search />; @@ -268,7 +286,7 @@ function PrivateRouting(): VNode { return <div>not yet implemented</div>; } case "dashboard": { - return <Dashboard routeDownloadStats={privatePages.statsDownload} />; + return <Dashboard routeToDownloadStats={privatePages.statsDownload} />; } default: assertUnreachable(location); diff --git a/packages/aml-backoffice-ui/src/hooks/custom-measures.ts b/packages/aml-backoffice-ui/src/hooks/custom-measures.ts @@ -0,0 +1,104 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + Codec, + KycRule, + MeasureInformation, + buildCodecForObject, + codecForAbsoluteTime, + codecForAny, + codecForBoolean, + codecForConstString, + codecForEither, + codecForEmptyObject, + codecForKycRules, + codecForList, + codecForMap, + codecForMeasureInformation, + codecForString, + codecOptional, + codecOptionalDefault, +} from "@gnu-taler/taler-util"; +import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; + +export interface CustomMeasures { + measures: CustomMeasure[]; +} + +type CustomMeasure = { + type: "procedure" | "form"; + program: string; + name: string; + check_name: string; + context: Object; +}; + +export const codecForCustomMeasures = (): Codec<CustomMeasures> => + buildCodecForObject<CustomMeasures>() + .property( + "measures", + codecOptionalDefault(codecForList(codecForCustomMeasure()), []), + ) + .build("CustomMeasures"); +export const codecForCustomMeasure = (): Codec<CustomMeasure> => + buildCodecForObject<CustomMeasure>() + .property("check_name", codecForString()) + .property("program", codecForString()) + .property("name", codecForString()) + .property("context", codecForAny()) + .property( + "type", + codecForEither( + codecForConstString("procedure"), + codecForConstString("form"), + ), + ) + .build("CustomMeasure"); + +const defaultCustomMeasures: CustomMeasures = { + measures: [], +}; + +const CUSTOM_MEASURE_KEY = buildStorageKey( + "aml-custom-measures", + codecForCustomMeasures(), +); +/** + * User preferences. + * + * @returns tuple of [state, update()] + */ +export function useCustomMeasures(): [ + Readonly<CustomMeasures>, + <T extends keyof CustomMeasures>(key: T, value: CustomMeasures[T]) => void, + (s: CustomMeasures) => void, +] { + const { value, update } = useLocalStorage( + CUSTOM_MEASURE_KEY, + defaultCustomMeasures, + ); + + function updateField<T extends keyof CustomMeasures>( + k: T, + v: CustomMeasures[T], + ) { + const newValue = { ...value, [k]: v }; + update(newValue); + } + return [value, updateField, update]; +} diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -62,11 +62,12 @@ import { useAccountInformation } from "../hooks/account.js"; import { DecisionRequest } from "../hooks/decision-request.js"; import { useAccountDecisions } from "../hooks/decisions.js"; import { useOfficer } from "../hooks/officer.js"; -import { CurrentMeasureTable, MeasureInfo } from "./MeasuresTable.js"; +import { CurrentMeasureTable, MeasureInfo, Mesaures } 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"; +import { CustomMeasures, useCustomMeasures } from "../hooks/custom-measures.js"; export type AmlEvent = | AmlFormEvent @@ -670,8 +671,7 @@ function SubmitNewDecision({ <div class="my-2"> <ShowMesaureInfo - nextMeasures={separateMeasures(decision.request.new_measures)} - customMeasure={decision.request.new_rules.custom_measures} + nextMeasures={decision.request.new_measures?.split(" ") ?? []} /> </div> @@ -690,22 +690,10 @@ function SubmitNewDecision({ ); } -function separateMeasures(measureList: string | undefined): string[][] { - if (!measureList) return new Array(); - const orList = measureList.trim().split(" "); - const orAndList = orList.map((or) => or.split("+")); - return orAndList; -} - -function ShowMesaureInfo({ - nextMeasures, - customMeasure, -}: { - nextMeasures: string[][]; - customMeasure: { [d: string]: TalerExchangeApi.MeasureInformation }; -}): VNode { +function ShowMesaureInfo({ nextMeasures }: { nextMeasures: string[] }): VNode { const { i18n } = useTranslationContext(); const measures = useServerMeasures(); + const [cm] = useCustomMeasures(); if (!measures) { return <Loading />; } @@ -731,44 +719,32 @@ function ShowMesaureInfo({ assertUnreachable(measures.case); } } - const map = computeAvailableMesaures(measures.body, customMeasure); - - const filteredMeasures = nextMeasures.filter((n) => !!n.length); + const filteredMeasures = nextMeasures.filter((n) => !!n && !!n.trim()); + const allMeasures = computeAvailableMesaures( + measures.body, + cm, + filteredMeasures, + ); if (!filteredMeasures.length) { return <Fragment />; } if (filteredMeasures.length === 1) { - const measurePath = filteredMeasures[0]; - if (measurePath.length === 1) { - const m = map[measurePath[0]]; - - return ( - <div> - <i18n.Translate> - The customer needs to complete this measure - </i18n.Translate> - <CurrentMeasureTable list={[m]} /> - </div> - ); - } return ( <div> <i18n.Translate> - The customer needs to complete all of these measures + The customer needs to complete this measure </i18n.Translate> - <CurrentMeasureTable list={measurePath.map((name) => map[name])} /> + <CurrentMeasureTable measures={allMeasures} /> </div> ); } - return ( <div> <i18n.Translate> - The user has more than one option with different measures to complete. - For any options, if there is more than one measure then the user will - need to complete all the measures. + The customer needs to complete all of these measures </i18n.Translate> + <CurrentMeasureTable measures={allMeasures} /> </div> ); } @@ -1427,6 +1403,7 @@ export function ShowMeasuresToSelect({ }): VNode { const measures = useServerMeasures(); const { i18n } = useTranslationContext(); + const [cm] = useCustomMeasures(); if (!measures) { return <Loading />; } @@ -1452,54 +1429,84 @@ export function ShowMeasuresToSelect({ assertUnreachable(measures.case); } } - const list = Object.entries(measures.body.roots).map( - ([key, value]): MeasureInfo => { + + return ( + <CurrentMeasureTable + measures={computeAvailableMesaures(measures.body, cm)} + onSelect={onSelect} + /> + ); +} + +export function computeAvailableMesaures( + serverMeasures: TalerExchangeApi.AvailableMeasureSummary | undefined, + customMeasures?: Readonly<CustomMeasures>, + filter?: string[], +): Mesaures { + const init: Mesaures = { forms: [], procedures: [] }; + if (!serverMeasures) { + return init; + } + const server = Object.entries(serverMeasures.roots).reduce( + (prev, [key, value]) => { + if (filter !== undefined && filter.indexOf(key) === -1) { + // if filter has been given and the measure is not in the list + // then skip + return prev; + } if (value.check_name !== "SKIP") { - return { + prev.forms.push({ + type: "form", name: key, context: value.context, - program: measures.body.programs[value.prog_name], - check: measures.body.checks[value.check_name], - }; + program: serverMeasures.programs[value.prog_name], + check: serverMeasures.checks[value.check_name], + custom: false, + }); + } else { + prev.procedures.push({ + type: "procedure", + name: key, + context: value.context, + program: serverMeasures.programs[value.prog_name], + custom: false, + }); } - return { - name: key, - context: value.context, - program: measures.body.programs[value.prog_name], - }; + return prev; }, + init, ); - return <CurrentMeasureTable list={list} onSelect={onSelect} />; -} - -export function computeAvailableMesaures( - server: TalerExchangeApi.AvailableMeasureSummary, - custom: TalerExchangeApi.AvailableMeasureSummary["roots"], -): { [name: string]: MeasureInfo } { - const result: { [d: string]: MeasureInfo } = {}; + if (!customMeasures) { + return server; + } - function addUpIntoMap([key, value]: [ - string, - TalerExchangeApi.MeasureInformation, - ]): void { + const serverAndCustom = customMeasures.measures.reduce((prev, value) => { + if (filter !== undefined && filter.indexOf(value.name) === -1) { + // if filter has been given and the measure is not in the list + // then skip + return prev; + } if (value.check_name !== "SKIP") { - result[key] = { - name: key, + prev.forms.push({ + type: "form", + name: value.name, context: value.context, - program: server.programs[value.prog_name], - check: server.checks[value.check_name], - }; + program: serverMeasures.programs[value.program], + check: serverMeasures.checks[value.check_name], + custom: true, + }); } else { - result[key] = { - name: key, + prev.procedures.push({ + type: "procedure", + name: value.name, context: value.context, - program: server.programs[value.prog_name], - }; + program: serverMeasures.programs[value.program], + custom: true, + }); } - } - Object.entries(server.roots).forEach(addUpIntoMap); - Object.entries(custom).forEach(addUpIntoMap); + return prev; + }, server); - return result; + return serverAndCustom; } diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -191,9 +191,9 @@ export function CasesUI({ } export function Cases({ - caseByIdRoute, + routeToCaseById, }: { - caseByIdRoute: RouteDefinition<{ cid: string }>; + routeToCaseById: RouteDefinition<{ cid: string }>; }) { const list = useCurrentDecisions(); const { i18n } = useTranslationContext(); @@ -254,7 +254,7 @@ export function Cases({ <CasesUI filtered={false} records={list.body} - caseByIdRoute={caseByIdRoute} + caseByIdRoute={routeToCaseById} onFirstPage={list.isFirstPage ? undefined : list.loadFirst} onNext={list.isLastPage ? undefined : list.loadNext} // filter={stateFilter} @@ -265,9 +265,9 @@ export function Cases({ ); } export function CasesUnderInvestigation({ - caseByIdRoute, + routeToCaseById, }: { - caseByIdRoute: RouteDefinition<{ cid: string }>; + routeToCaseById: RouteDefinition<{ cid: string }>; }) { const list = useCurrentDecisionsUnderInvestigation(); const { i18n } = useTranslationContext(); @@ -328,7 +328,7 @@ export function CasesUnderInvestigation({ <CasesUI filtered={true} records={list.body} - caseByIdRoute={caseByIdRoute} + caseByIdRoute={routeToCaseById} onFirstPage={list.isFirstPage ? undefined : list.loadFirst} onNext={list.isLastPage ? undefined : list.loadNext} // filter={stateFilter} diff --git a/packages/aml-backoffice-ui/src/pages/Dashboard.tsx b/packages/aml-backoffice-ui/src/pages/Dashboard.tsx @@ -38,9 +38,9 @@ import { AmlEventsName } from "./decision/aml-events.js"; import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; export function Dashboard({ - routeDownloadStats, + routeToDownloadStats, }: { - routeDownloadStats: RouteDefinition; + routeToDownloadStats: RouteDefinition; }) { const officer = useOfficer(); const { i18n } = useTranslationContext(); @@ -54,7 +54,7 @@ export function Dashboard({ <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 "> <i18n.Translate>Dashboard</i18n.Translate> </h1> - <EventMetrics routeDownloadStats={routeDownloadStats} /> + <EventMetrics routeToDownloadStats={routeToDownloadStats} /> </div> ); } @@ -62,9 +62,9 @@ export function Dashboard({ const now = new Date(); function EventMetrics({ - routeDownloadStats, + routeToDownloadStats, }: { - routeDownloadStats: RouteDefinition; + routeToDownloadStats: RouteDefinition; }): VNode { const { i18n, dateLocale } = useTranslationContext(); const [metricType, setMetricType] = @@ -138,7 +138,7 @@ function EventMetrics({ </dl> <div class="flex justify-end mt-4"> <a - href={routeDownloadStats.url({})} + href={routeToDownloadStats.url({})} name="download stats" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" > diff --git a/packages/aml-backoffice-ui/src/pages/Measures.tsx b/packages/aml-backoffice-ui/src/pages/Measures.tsx @@ -14,28 +14,28 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - AmlProgramRequirement, assertUnreachable, HttpStatusCode, - KycCheckInformation, TalerError, } from "@gnu-taler/taler-util"; import { Attention, Loading, - useExchangeApiContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { Fragment, h } from "preact"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; -import { CurrentMeasureTable, MeasureInfo } from "./MeasuresTable.js"; -import { Officer } from "./Officer.js"; +import { useCustomMeasures } from "../hooks/custom-measures.js"; import { useServerMeasures } from "../hooks/server-info.js"; +import { CurrentMeasureTable, Mesaures } from "./MeasuresTable.js"; +import { Officer } from "./Officer.js"; +import { computeAvailableMesaures } from "./CaseDetails.js"; export function Measures({}: {}) { const { i18n } = useTranslationContext(); const measures = useServerMeasures(); + const [custom] = useCustomMeasures(); if (!measures) { return <Loading />; @@ -64,23 +64,7 @@ export function Measures({}: {}) { } } - const list = Object.entries(measures.body.roots).map( - ([key, value]): MeasureInfo => { - if (value.check_name !== "SKIP") { - return { - name: key, - context: value.context, - program: measures.body.programs[value.prog_name], - check: measures.body.checks[value.check_name], - }; - } - return { - name: key, - context: value.context, - program: measures.body.programs[value.prog_name], - }; - }, - ); + const ms = computeAvailableMesaures(measures.body, custom); return ( <div> @@ -92,22 +76,21 @@ export function Measures({}: {}) { </h1> <p class="mt-2 text-sm text-gray-700"> <i18n.Translate> - A list of all the pre-define measures in your that can used with - the user. + A list of all the pre-define measures in your that can used. </i18n.Translate> </p> </div> <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> - {/* <button - type="button" - class="block rounded-md bg-indigo-600 px-3 py-2 text-center 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" - > - Add user - </button> */} + <button + type="button" + class="block rounded-md bg-indigo-600 px-3 py-2 text-center 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>Add custom measure</i18n.Translate> + </button> </div> </div> - <CurrentMeasureTable list={list} /> + <CurrentMeasureTable measures={ms} /> </div> </div> ); diff --git a/packages/aml-backoffice-ui/src/pages/MeasuresTable.tsx b/packages/aml-backoffice-ui/src/pages/MeasuresTable.tsx @@ -20,104 +20,217 @@ import { import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -export type MeasureInfo = { +export type MeasureInfo = ProcedureMeasure | FormMeasure; + +export type ProcedureMeasure = { + type: "procedure"; + name: string; + program: AmlProgramRequirement; + context?: object; + custom: boolean; +}; +export type FormMeasure = { + type: "form"; name: string; program: AmlProgramRequirement; - check?: KycCheckInformation; + check: KycCheckInformation; context?: object; + custom: boolean; +}; +export type Mesaures = { + procedures: ProcedureMeasure[]; + forms: FormMeasure[]; }; export function CurrentMeasureTable({ - list, + measures, onSelect, }: { - list: MeasureInfo[]; + measures: Mesaures; onSelect?: (m: MeasureInfo) => void; }): VNode { const { i18n } = useTranslationContext(); return ( - <div class="mt-4 flow-root"> - <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> - <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> - <div class="overflow-hidden shadow ring-1 ring-black/5 sm:rounded-lg"> - <table class="min-w-full divide-y divide-gray-300"> - <thead class="bg-gray-50"> - <tr> - {onSelect ? ( - <th scope="col" class="relative p-2 "> - <span class="sr-only">Select</span> - </th> - ) : ( - <Fragment /> - )} - <th - scope="col" - class="p-2 text-left text-sm font-semibold text-gray-900 sm:pl-6" - > - <i18n.Translate>Name</i18n.Translate> - </th> - <th - scope="col" - class="p-2 text-left text-sm font-semibold text-gray-900" - > - <i18n.Translate>Check</i18n.Translate> - </th> - <th - scope="col" - class="p-2 text-left text-sm font-semibold text-gray-900" - > - <i18n.Translate>Program</i18n.Translate> - </th> - <th - scope="col" - class="p-2 text-left text-sm font-semibold text-gray-900" - > - <i18n.Translate>Context</i18n.Translate> - </th> - </tr> - </thead> - <tbody class="divide-y divide-gray-200 bg-white"> - {list.map((m) => { - if ( - m.context && - "internal" in m.context && - m.context.internal - ) { - return <Fragment />; - } - return ( + <Fragment> + {!measures.forms.length ? undefined : ( + <div class="mt-4 flow-root"> + <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold text-gray-900"> + <i18n.Translate>Forms</i18n.Translate> + </h1> + </div> + </div> + + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + <div class="overflow-hidden shadow ring-1 ring-black/5 sm:rounded-lg"> + <table class="min-w-full divide-y divide-gray-300"> + <thead class="bg-gray-50"> + <tr> + {onSelect ? ( + <th scope="col" class="relative p-2 "> + <span class="sr-only">Select</span> + </th> + ) : ( + <Fragment /> + )} + <th + scope="col" + class="p-2 text-left text-sm font-semibold text-gray-900 sm:pl-6" + > + <i18n.Translate>Name</i18n.Translate> + </th> + <th + scope="col" + class="p-2 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate>Check</i18n.Translate> + </th> + <th + scope="col" + class="p-2 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate>Program</i18n.Translate> + </th> + <th + scope="col" + class="p-2 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate>Context</i18n.Translate> + </th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200 bg-white"> + {measures.forms.map((m) => { + // if ( + // m.context && + // "internal" in m.context && + // m.context.internal + // ) { + // return <Fragment />; + // } + return ( + <tr> + {onSelect ? ( + <td class="relative whitespace-nowrap p-2 text-right text-sm font-medium "> + <button + onClick={() => onSelect(m)} + class="rounded-md w-fit border-0 p-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" + > + <i18n.Translate>Select</i18n.Translate> + </button> + </td> + ) : ( + <Fragment /> + )} + <td class="whitespace-nowrap p-2 text-sm font-medium text-gray-900 sm:pl-6"> + {m.name} + </td> + <td class="whitespace-nowrap p-2 text-sm text-gray-500"> + {m.check?.description ?? ""} + </td> + <td class="whitespace-nowrap p-2 text-sm text-gray-500"> + {m.program.description} + </td> + <td class="whitespace-nowrap p-2 text-sm text-gray-500"> + {Object.keys(m.context ?? {}).join(", ")} + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + </div> + </div> + </div> + )} + + {!measures.procedures.length ? undefined : ( + <div class="mt-4 flow-root"> + <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold text-gray-900"> + <i18n.Translate>Procedures</i18n.Translate> + </h1> + </div> + </div> + + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + <div class="overflow-hidden shadow ring-1 ring-black/5 sm:rounded-lg"> + <table class="min-w-full divide-y divide-gray-300"> + <thead class="bg-gray-50"> <tr> {onSelect ? ( - <td class="relative whitespace-nowrap p-2 text-right text-sm font-medium "> - <button - onClick={() => onSelect(m)} - class="rounded-md w-fit border-0 p-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" - > - <i18n.Translate>Select</i18n.Translate> - </button> - </td> + <th scope="col" class="relative p-2 "> + <span class="sr-only">Select</span> + </th> ) : ( <Fragment /> )} - <td class="whitespace-nowrap p-2 text-sm font-medium text-gray-900 sm:pl-6"> - {m.name} - </td> - <td class="whitespace-nowrap p-2 text-sm text-gray-500"> - {m.check?.description ?? ""} - </td> - <td class="whitespace-nowrap p-2 text-sm text-gray-500"> - {m.program.description} - </td> - <td class="whitespace-nowrap p-2 text-sm text-gray-500"> - {Object.keys(m.context ?? {}).join(", ")} - </td> + <th + scope="col" + class="p-2 text-left text-sm font-semibold text-gray-900 sm:pl-6" + > + <i18n.Translate>Name</i18n.Translate> + </th> + <th + scope="col" + class="p-2 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate>Program</i18n.Translate> + </th> + <th + scope="col" + class="p-2 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate>Context</i18n.Translate> + </th> </tr> - ); - })} - </tbody> - </table> + </thead> + <tbody class="divide-y divide-gray-200 bg-white"> + {measures.procedures.map((m) => { + // if ( + // m.context && + // "internal" in m.context && + // m.context.internal + // ) { + // return <Fragment />; + // } + return ( + <tr> + {onSelect ? ( + <td class="relative whitespace-nowrap p-2 text-right text-sm font-medium "> + <button + onClick={() => onSelect(m)} + class="rounded-md w-fit border-0 p-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" + > + <i18n.Translate>Select</i18n.Translate> + </button> + </td> + ) : ( + <Fragment /> + )} + <td class="whitespace-nowrap p-2 text-sm font-medium text-gray-900 sm:pl-6"> + {m.name} + </td> + <td class="whitespace-nowrap p-2 text-sm text-gray-500"> + {m.program.description} + </td> + <td class="whitespace-nowrap p-2 text-sm text-gray-500"> + {Object.keys(m.context ?? {}).join(", ")} + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + </div> </div> </div> - </div> - </div> + )} + </Fragment> ); } diff --git a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx @@ -109,7 +109,7 @@ export function AmlDecisionRequestWizard({ case "justification": return <Justification />; case "summary": - return <Summary account={account} />; + return <Summary account={account} onMove={onMove} />; } assertUnreachable(stepOrDefault); })(); diff --git a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx @@ -14,6 +14,7 @@ import { h, VNode } from "preact"; import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; import { ShowMeasuresToSelect } from "../CaseDetails.js"; import { useServerMeasures } from "../../hooks/server-info.js"; +import { useMemo } from "preact/hooks"; /** * Ask for more information, define new paths to proceed @@ -29,9 +30,9 @@ export function Measures({}: {}): VNode { ? [] : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi })); - const initValue: FormType = !request.new_measures - ? { measures: [] } - : { measures: request.new_measures }; + const nm = !request.new_measures ? [] : request.new_measures; + + const initValue = useMemo<FormType>(() => ({ measures: nm }), [nm]); const design = formDesign(i18n, measureList); const form = useForm<FormType>(design, initValue); diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx @@ -20,29 +20,42 @@ import { computeAvailableMesaures, ShowDecisionLimitInfo, } from "../CaseDetails.js"; -import { CurrentMeasureTable } from "../MeasuresTable.js"; +import { CurrentMeasureTable, Mesaures } from "../MeasuresTable.js"; import { useOfficer } from "../../hooks/officer.js"; +import { WizardSteps } from "./AmlDecisionRequestWizard.js"; +import { useCustomMeasures } from "../../hooks/custom-measures.js"; /** * Mark for further investigation and explain decision * @param param0 * @returns */ -export function Summary({ account }: { account?: string }): VNode { +export function Summary({ + account, + onMove, +}: { + account?: string; + onMove: (n: WizardSteps | undefined) => void; +}): VNode { const { i18n } = useTranslationContext(); const [decision, _, updateDecision] = useCurrentDecisionRequest(); const measures = useServerMeasures(); const [notification, withErrorHandler] = useLocalNotificationHandler(); const officer = useOfficer(); const session = officer.state === "ready" ? officer.account : undefined; - - const allMeasures = + const [custom] = useCustomMeasures(); + const allMeasures = computeAvailableMesaures( !measures || measures instanceof TalerError || measures.type === "fail" - ? [] - : Object.values(computeAvailableMesaures(measures.body, {})); + ? undefined + : measures.body, + custom, + ); const d = decision.new_measures === undefined ? [] : decision.new_measures; - const activeMeasureInfo = allMeasures.filter((m) => d.indexOf(m.name) !== -1); + const activeMeasureInfo: Mesaures = { + forms: allMeasures.forms.filter((m) => d.indexOf(m.name) !== -1), + procedures: allMeasures.procedures.filter((m) => d.indexOf(m.name) !== -1), + }; const { lib } = useExchangeApiContext(); @@ -53,6 +66,7 @@ export function Summary({ account }: { account?: string }): VNode { const INVALID_JUSTIFICATION = decision.justification === undefined || !decision.justification; const INVALID_ACCOUNT = !account; + const CANT_SUBMIT = INVALID_ACCOUNT || INVALID_EVENTS || @@ -61,6 +75,22 @@ export function Summary({ account }: { account?: string }): VNode { INVALID_PROPERTIES || INVALID_RULES; + function clearUp() { + 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, + }); + onMove(undefined); + } + const submitHandler = CANT_SUBMIT || !session ? undefined @@ -87,18 +117,7 @@ export function Summary({ account }: { account?: string }): VNode { 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, - }); + clearUp(); }, (fail) => { switch (fail.case) { @@ -125,7 +144,11 @@ export function Summary({ account }: { account?: string }): VNode { {INVALID_RULES ? ( <Fragment> {!decision.deadline && ( - <Attention type="danger" title={i18n.str`Missing deadline`}> + <Attention + type="danger" + title={i18n.str`Missing deadline`} + onClose={() => onMove("justification")} + > <i18n.Translate> Deadline should specify when this rules ends and what is the next measures to apply after expiration. @@ -133,7 +156,11 @@ export function Summary({ account }: { account?: string }): VNode { </Attention> )} {!decision.rules && ( - <Attention type="danger" title={i18n.str`Missing rules`}> + <Attention + type="danger" + title={i18n.str`Missing rules`} + onClose={() => onMove("rules")} + > <i18n.Translate> Can't make a decision without rules. </i18n.Translate> @@ -155,20 +182,32 @@ export function Summary({ account }: { account?: string }): VNode { </div> )} {INVALID_MEASURES ? ( - <Attention type="danger" title={i18n.str`Missing active measure`}> + <Attention + type="danger" + title={i18n.str`Missing active measure`} + onClose={() => onMove("measures")} + > <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.`}> + <Attention + type="info" + title={i18n.str`No customer action required.`} + onClose={() => onMove("measures")} + > <i18n.Translate>No active measure has been selected.</i18n.Translate> </Attention> ) : ( - <CurrentMeasureTable list={activeMeasureInfo} /> + <CurrentMeasureTable measures={activeMeasureInfo} /> )} {INVALID_PROPERTIES ? ( - <Attention type="danger" title={i18n.str`Missing properties`}> + <Attention + type="danger" + title={i18n.str`Missing properties`} + onClose={() => onMove("properties")} + > <i18n.Translate> You should specify in the properties section. </i18n.Translate> @@ -177,7 +216,11 @@ export function Summary({ account }: { account?: string }): VNode { <div /> )} {INVALID_EVENTS ? ( - <Attention type="danger" title={i18n.str`Missing events`}> + <Attention + type="danger" + title={i18n.str`Missing events`} + onClose={() => onMove("events")} + > <i18n.Translate> You should specify in the properties section. </i18n.Translate> @@ -186,7 +229,11 @@ export function Summary({ account }: { account?: string }): VNode { <div /> )} {INVALID_JUSTIFICATION ? ( - <Attention type="danger" title={i18n.str`Missing justification`}> + <Attention + type="danger" + title={i18n.str`Missing justification`} + onClose={() => onMove("justification")} + > <i18n.Translate> You should specify in the properties section. </i18n.Translate> @@ -195,14 +242,22 @@ export function Summary({ account }: { account?: string }): VNode { <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> + <div class="mt-2 flex justify-between"> + <button + onClick={clearUp} + class="mt-4 disabled:opacity-50 disabled:cursor-default rounded-md px-3 py-2 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + > + <i18n.Translate>Clear</i18n.Translate> + </button> + <Button + type="submit" + handler={submitHandler} + disabled={!submitHandler} + class="mt-4 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> + </div> </Fragment> ); }