commit 8d834d43c51f58140e31581656a1a4cfad90aea6 parent e9a3fce8d605e11360fe40dba74474f0585856f1 Author: Sebastian <sebasjm@gmail.com> Date: Tue, 21 Jan 2025 18:17:07 -0300 dashboard Diffstat:
20 files changed, 1356 insertions(+), 260 deletions(-)
diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx @@ -218,7 +218,7 @@ export function ExchangeAmlFrame({ <div class="-mt-32 flex grow "> {officer?.state !== "ready" ? undefined : <Navigation />} - <div class="flex mx-auto my-4"> + <div class="flex mx-auto my-4 min-w-80"> <main class="block rounded-lg bg-white px-5 py-6 shadow " style={{ minWidth: 600 }} @@ -241,7 +241,11 @@ function Navigation(): VNode { const { i18n } = useTranslationContext(); const [{ showDebugInfo }] = usePreferences(); const pageList = [ - { route: privatePages.profile, Icon: PeopleIcon, label: i18n.str`Profile` }, + { + route: privatePages.dashboard, + Icon: PeopleIcon, + label: i18n.str`Dashboard`, + }, { route: privatePages.investigation, Icon: ToInvestigateIcon, @@ -253,6 +257,7 @@ function Navigation(): VNode { Icon: SearchIcon, label: i18n.str`Search`, }, + { route: privatePages.profile, Icon: PeopleIcon, label: i18n.str`Profile` }, showDebugInfo ? { route: privatePages.measures, Icon: FormIcon, label: i18n.str`Forms` } : undefined, diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx @@ -43,6 +43,7 @@ import { WizardSteps, } from "./pages/decision/AmlDecisionRequestWizard.js"; import { useCurrentDecisionRequest } from "./hooks/decision-request.js"; +import { Dashboard } from "./pages/Dashboard.js"; export function Routing(): VNode { const session = useOfficer(); @@ -107,6 +108,8 @@ function PublicRounting(): VNode { export const privatePages = { profile: urlPattern(/\/profile/, () => "#/profile"), + dashboard: urlPattern(/\/dashboard/, () => "#/dashboard"), + statsDownload: urlPattern(/\/download-stats/, () => "#/download-stats"), decideWithStep: urlPattern<{ cid: string; step: string }>( /\/decide\/(?<cid>[a-zA-Z0-9]+)\/(?<step>[a-z]+)/, ({ cid, step }) => `#/decide/${cid}/${step}`, @@ -147,7 +150,7 @@ function PrivateRouting(): VNode { const [request, _, updateRequest] = useCurrentDecisionRequest(); useEffect(() => { if (location.name === undefined) { - navigateTo(privatePages.profile.url({})); + navigateTo(privatePages.dashboard.url({})); } }, [location]); @@ -261,6 +264,12 @@ function PrivateRouting(): VNode { case "search": { return <Search />; } + case "statsDownload": { + return <div>not yet implemented</div>; + } + case "dashboard": { + return <Dashboard routeDownloadStats={privatePages.statsDownload} />; + } default: assertUnreachable(location); } diff --git a/packages/aml-backoffice-ui/src/hooks/account.ts b/packages/aml-backoffice-ui/src/hooks/account.ts @@ -68,35 +68,3 @@ export function useAccountInformation(paytoHash?: string) { if (error) return error; return undefined; } - -export function useServerMeasures() { - const officer = useOfficer(); - const session = officer.state === "ready" ? officer.account : undefined; - - const { - lib: { exchange: api }, - } = useExchangeApiContext(); - - async function fetcher([officer]: [OfficerAccount]) { - return await api.getAmlMesasures(officer); - } - - const { data, error } = useSWR< - TalerExchangeResultByMethod<"getAmlMesasures">, - TalerHttpError - >(!session ? undefined : [session], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); - - if (data) return data; - if (error) return error; - return undefined; -} diff --git a/packages/aml-backoffice-ui/src/hooks/decision-request.ts b/packages/aml-backoffice-ui/src/hooks/decision-request.ts @@ -35,14 +35,15 @@ import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; export interface DecisionRequest { rules: KycRule[] | undefined; + new_measures: string | undefined; deadline: AbsoluteTime | undefined; - properties: object | undefined; - custom_properties: object | undefined; + onExpire_measures: string | undefined; + properties: Record<string, any> | undefined; + custom_properties: Record<string, any> | undefined; custom_events: string[] | undefined; inhibit_events: string[] | undefined; keep_investigating: boolean; justification: string | undefined; - new_measures: string | undefined; } export const codecForDecisionRequest = (): Codec<DecisionRequest> => @@ -59,10 +60,12 @@ export const codecForDecisionRequest = (): Codec<DecisionRequest> => codecOptionalDefault(codecForBoolean(), false), ) .property("new_measures", codecOptional(codecForString())) + .property("onExpire_measures", codecOptional(codecForString())) .build("DecisionRequest"); const defaultDecisionRequest: DecisionRequest = { deadline: undefined, + onExpire_measures: undefined, custom_events: undefined, inhibit_events: undefined, justification: undefined, diff --git a/packages/aml-backoffice-ui/src/hooks/server-info.ts b/packages/aml-backoffice-ui/src/hooks/server-info.ts @@ -0,0 +1,146 @@ +/* + 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, + AmlSpaDialect, + assertUnreachable, + EventCounter, + OfficerAccount, + OperationOk, + opFixedSuccess, + PaytoString, + TalerCorebankApi, + TalerExchangeResultByMethod, + TalerHttpError, +} from "@gnu-taler/taler-util"; +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import { useExchangeApiContext } from "@gnu-taler/web-util/browser"; +import _useSWR, { mutate, SWRHook } from "swr"; +import { useOfficer } from "./officer.js"; +import { AmlProgram } from "../../../taler-util/lib/types-taler-kyc-aml.js"; +import { + AML_EVENTS_INFO, + AmlEventsName, +} from "../pages/decision/aml-events.js"; +import { useMemo } from "preact/hooks"; +import { Timeframe } from "../pages/Dashboard.js"; +const useSWR = _useSWR as unknown as SWRHook; + +export function useServerMeasures() { + const officer = useOfficer(); + const session = officer.state === "ready" ? officer.account : undefined; + + const { + lib: { exchange: api }, + } = useExchangeApiContext(); + + async function fetcher([officer]: [OfficerAccount]) { + return await api.getAmlMesasures(officer); + } + + const { data, error } = useSWR< + TalerExchangeResultByMethod<"getAmlMesasures">, + TalerHttpError + >(!session ? undefined : [session], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (data) return data; + if (error) return error; + return undefined; +} + +export type ServerStats = { + [name: string]: EventCounter; +}; + +export function useServerStatistics( + dialect: AmlSpaDialect, + current: Timeframe, + previous?: Timeframe, +) { + const officer = useOfficer(); + const session = officer.state === "ready" ? officer.account : undefined; + + const { + lib: { exchange: api }, + } = useExchangeApiContext(); + + const keys = useMemo(() => { + return Object.entries(AML_EVENTS_INFO) + .filter(([name, info]) => { + return info.dialect.includes(dialect); + }) + .map(([name]) => name as AmlEventsName); + }, [dialect]); + + async function fetcher([officer, keys, current, previous]: [ + OfficerAccount, + AmlEventsName[], + Timeframe, + Timeframe | undefined, + ]) { + const queries = keys.map((key) => { + return Promise.all([ + api.getAmlKycStatistics(officer, key, { + since: current.start, + until: current.end, + }), + !previous + ? undefined + : api.getAmlKycStatistics(officer, key, { + since: previous.start, + until: previous.end, + }), + ]).then(([c, p]) => { + return { key, current: c.body, previous: p?.body }; + }); + }); + + const p = await Promise.all(queries); + + return opFixedSuccess(p); + } + + const { data, error } = useSWR< + OperationOk< + { key: AmlEventsName; current: EventCounter; previous?: EventCounter }[] + >, + TalerHttpError + >(!session ? undefined : [session, keys, current, previous], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (data) return data; + if (error) return error; + return undefined; +} diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -58,13 +58,14 @@ import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { useUiFormsContext } from "../context/ui-forms.js"; import { preloadedForms } from "../forms/index.js"; -import { useAccountInformation, useServerMeasures } from "../hooks/account.js"; +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 { Officer } from "./Officer.js"; import { ShowConsolidated } from "./ShowConsolidated.js"; +import { useServerMeasures } from "../hooks/server-info.js"; export type AmlEvent = | AmlFormEvent @@ -142,13 +143,13 @@ export function CaseDetails({ // paytoString?: PaytoString; }) { const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now()); - const [request, setDesicionRequest] = useState<NewDecision | undefined>( - undefined, - ); + // const [request, setDesicionRequest] = useState<NewDecision | undefined>( + // undefined, + // ); // const [decisionWizardStep, setDecisionWizardStep] = // useState<WizardSteps>("events"); - const [selectMeasure, setSelectMeasure] = useState<boolean>(); - const { config } = useExchangeApiContext(); + // const [selectMeasure, setSelectMeasure] = useState<boolean>(); + // const { config } = useExchangeApiContext(); const { i18n } = useTranslationContext(); const details = useAccountInformation(account); @@ -196,47 +197,47 @@ export function CaseDetails({ const events = getEventsFromAmlHistory(accountDetails, i18n, allForms); - if (selectMeasure) { - return ( - <ShowMeasuresToSelect - onSelect={(d) => { - setSelectMeasure(false); - setDesicionRequest({ - request: { - // payto_uri: paytoString, - decision_time: AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.now(), - ), - h_payto: account, - keep_investigating: false, - properties: {}, - // the custom measure with context - new_measures: d.name, - new_rules: { - // this value is going to be overridden - custom_measures: {}, - expiration_time: AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.never(), - ), - rules: FREEZE_RULES(config.config.currency), - }, - }, - askInformation: false, - }); - }} - /> - ); - } - if (request) { - return ( - <SubmitNewDecision - decision={request} - onComplete={() => { - setDesicionRequest(undefined); - }} - /> - ); - } + // if (selectMeasure) { + // return ( + // <ShowMeasuresToSelect + // onSelect={(d) => { + // setSelectMeasure(false); + // setDesicionRequest({ + // request: { + // // payto_uri: paytoString, + // decision_time: AbsoluteTime.toProtocolTimestamp( + // AbsoluteTime.now(), + // ), + // h_payto: account, + // keep_investigating: false, + // properties: {}, + // // the custom measure with context + // new_measures: d.name, + // new_rules: { + // // this value is going to be overridden + // custom_measures: {}, + // expiration_time: AbsoluteTime.toProtocolTimestamp( + // AbsoluteTime.never(), + // ), + // rules: FREEZE_RULES(config.config.currency), + // }, + // }, + // askInformation: false, + // }); + // }} + // /> + // ); + // } + // if (request) { + // return ( + // <SubmitNewDecision + // decision={request} + // onComplete={() => { + // setDesicionRequest(undefined); + // }} + // /> + // ); + // } // if (decisionWizardStep) { // return ( // <AmlDecisionRequestWizard @@ -254,6 +255,7 @@ export function CaseDetails({ onNewDecision({ deadline: undefined, custom_properties: undefined, + onExpire_measures: undefined, custom_events: undefined, inhibit_events: undefined, justification: undefined, @@ -267,7 +269,7 @@ export function CaseDetails({ > <i18n.Translate>New decision</i18n.Translate> </button> - + {/* <button onClick={async () => { setDesicionRequest({ @@ -377,16 +379,16 @@ export function CaseDetails({ 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" > <i18n.Translate>Ask for more information</i18n.Translate> - </button> + </button> */} - <button + {/* <button onClick={async () => { setSelectMeasure(true); }} 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" > <i18n.Translate>Set predefined measure</i18n.Translate> - </button> + </button> */} </div> ); } diff --git a/packages/aml-backoffice-ui/src/pages/Dashboard.tsx b/packages/aml-backoffice-ui/src/pages/Dashboard.tsx @@ -0,0 +1,570 @@ +/* + 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, + AmlSpaDialect, + assertUnreachable, + TalerCorebankApi, + TalerError, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { + InternationalizationAPI, + RouteDefinition, + useExchangeApiContext, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { format, sub } from "date-fns"; +import { Fragment, h, VNode } from "preact"; +import { useMemo, useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { useOfficer } from "../hooks/officer.js"; +import { usePreferences } from "../hooks/preferences.js"; +import { useServerStatistics } from "../hooks/server-info.js"; +import { AmlEventsName } from "./decision/aml-events.js"; +import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; + +export function Dashboard({ + routeDownloadStats, +}: { + routeDownloadStats: RouteDefinition; +}) { + const officer = useOfficer(); + const { i18n } = useTranslationContext(); + + if (officer.state !== "ready") { + return <HandleAccountNotReady officer={officer} />; + } + + return ( + <div> + <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 "> + <i18n.Translate>Dashboard</i18n.Translate> + </h1> + <EventMetrics routeDownloadStats={routeDownloadStats} /> + </div> + ); +} + +const now = new Date(); + +function EventMetrics({ + routeDownloadStats, +}: { + routeDownloadStats: RouteDefinition; +}): VNode { + const { i18n, dateLocale } = useTranslationContext(); + const [metricType, setMetricType] = + useState<TalerCorebankApi.MonitorTimeframeParam>( + TalerCorebankApi.MonitorTimeframeParam.hour, + ); + const params = useMemo( + () => getTimeframesForDate(now, metricType), + [metricType], + ); + + const [pref] = usePreferences(); + const { config } = useExchangeApiContext(); + + const dialect = + (pref.testingDialect ? undefined : config.config.aml_spa_dialect) ?? + AmlSpaDialect.TESTING; + + const resp = useServerStatistics(dialect, params.current, params.previous); + + if (!resp) return <Fragment />; + if (resp instanceof TalerError) { + return <ErrorLoadingWithDebug error={resp} />; + } + + return ( + <div class="px-4 mt-4"> + <div class="sm:flex sm:items-center mb-4"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Events</i18n.Translate> + </h1> + </div> + </div> + + <SelectTimeframe timeframe={metricType} setTimeframe={setMetricType} /> + + <div class="w-full flex justify-between"> + <h1 class="text-base text-gray-900 mt-5"> + {i18n.str`Trading volume from ${getDateStringForTimeframe( + params.current.start, + metricType, + dateLocale, + )} to ${getDateStringForTimeframe( + params.current.end, + metricType, + dateLocale, + )}`} + </h1> + </div> + + <dl class="mt-5 grid grid-cols-1 md:grid-cols-4 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0"> + {resp.body.map((ev) => { + const label = labelForEvent(ev.key, i18n); + const desc = descriptionForEvent(ev.key, i18n); + return ( + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + {label} + {!desc ? undefined : ( + <div class="text-xs text-gray-500">{desc}</div> + )} + </dt> + <MetricValueNumber + current={ev.current.counter} + previous={ev.previous?.counter} + /> + </div> + ); + })} + </dl> + <div class="flex justify-end mt-4"> + <a + href={routeDownloadStats.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" + > + <i18n.Translate>Download stats as CSV</i18n.Translate> + </a> + </div> + </div> + ); +} + +function labelForEvent(name: AmlEventsName, i18n: InternationalizationAPI) { + switch (name) { + case AmlEventsName.ACCOUNT_FROZEN: + return i18n.str`Frozen accounts`; + case AmlEventsName.ACCOUNT_PEP: + return i18n.str`PEP persons`; + case AmlEventsName.TEST_EVENT_KEY_1: + return i18n.str`TEST_EVENT_KEY_1`; + case AmlEventsName.TEST_EVENT_KEY_2: + return i18n.str`TEST_EVENT_KEY_2`; + case AmlEventsName.TEST_EVENT_KEY_3: + return i18n.str`TEST_EVENT_KEY_3`; + case AmlEventsName.TEST_EVENT_KEY_4: + return i18n.str`TEST_EVENT_KEY_4`; + case AmlEventsName.TEST_EVENT_KEY_5: + return i18n.str`TEST_EVENT_KEY_5`; + case AmlEventsName.TEST_EVENT_KEY_6: + return i18n.str`TEST_EVENT_KEY_6`; + case AmlEventsName.TEST_EVENT_KEY_7: + return i18n.str`TEST_EVENT_KEY_7`; + case AmlEventsName.TEST_EVENT_KEY_8: + return i18n.str`TEST_EVENT_KEY_8`; + case AmlEventsName.TEST_EVENT_KEY_9: + return i18n.str`TEST_EVENT_KEY_9`; + case AmlEventsName.TEST_EVENT_KEY_10: + return i18n.str`TEST_EVENT_KEY_10`; + case AmlEventsName.TEST_EVENT_KEY_11: + return i18n.str`TEST_EVENT_KEY_11`; + case AmlEventsName.TEST_EVENT_KEY_12: + return i18n.str`TEST_EVENT_KEY_12`; + case AmlEventsName.TEST_EVENT_KEY_13: + return i18n.str`TEST_EVENT_KEY_13`; + case AmlEventsName.TEST_EVENT_KEY_14: + return i18n.str`TEST_EVENT_KEY_14`; + default: { + assertUnreachable(name); + } + } +} + +function descriptionForEvent( + name: AmlEventsName, + i18n: InternationalizationAPI, +): TranslatedString | undefined { + switch (name) { + case AmlEventsName.ACCOUNT_FROZEN: + return i18n.str`Accounts that can move funds`; + case AmlEventsName.ACCOUNT_PEP: + return i18n.str`Public exposed persons`; + case AmlEventsName.TEST_EVENT_KEY_1: + return i18n.str`TEST_EVENT_KEY_1`; + case AmlEventsName.TEST_EVENT_KEY_2: + return i18n.str`TEST_EVENT_KEY_2`; + case AmlEventsName.TEST_EVENT_KEY_3: + return i18n.str`TEST_EVENT_KEY_3`; + case AmlEventsName.TEST_EVENT_KEY_4: + return i18n.str`TEST_EVENT_KEY_4`; + case AmlEventsName.TEST_EVENT_KEY_5: + return i18n.str`TEST_EVENT_KEY_5`; + case AmlEventsName.TEST_EVENT_KEY_6: + return i18n.str`TEST_EVENT_KEY_6`; + case AmlEventsName.TEST_EVENT_KEY_7: + return i18n.str`TEST_EVENT_KEY_7`; + case AmlEventsName.TEST_EVENT_KEY_8: + return i18n.str`TEST_EVENT_KEY_8`; + case AmlEventsName.TEST_EVENT_KEY_9: + return i18n.str`TEST_EVENT_KEY_9`; + case AmlEventsName.TEST_EVENT_KEY_10: + return i18n.str`TEST_EVENT_KEY_10`; + case AmlEventsName.TEST_EVENT_KEY_11: + return i18n.str`TEST_EVENT_KEY_11`; + case AmlEventsName.TEST_EVENT_KEY_12: + return i18n.str`TEST_EVENT_KEY_12`; + case AmlEventsName.TEST_EVENT_KEY_13: + return i18n.str`TEST_EVENT_KEY_13`; + case AmlEventsName.TEST_EVENT_KEY_14: + return i18n.str`TEST_EVENT_KEY_14`; + default: { + assertUnreachable(name); + } + } +} + +function MetricValueNumber({ + current, + previous, +}: { + current: number | undefined; + previous: number | undefined; +}): VNode { + const { i18n } = useTranslationContext(); + + const cmp = current && previous ? (current < previous ? -1 : 1) : 0; + + const rate = + !current || Number.isNaN(current) || !previous || Number.isNaN(previous) + ? 0 + : cmp === -1 + ? 1 - Math.round(current) / Math.round(previous) + : cmp === 1 + ? Math.round(current) / Math.round(previous) - 1 + : 0; + + const negative = cmp === 0 ? undefined : cmp === -1; + const rateStr = `${(Math.abs(rate) * 100).toFixed(2)}%`; + return ( + <Fragment> + <dd class="mt-1 block "> + <div class="flex justify-start text-2xl items-baseline font-semibold text-indigo-600"> + {!current ? "-" : current} + </div> + <div class="flex flex-col"> + <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600"> + <small class="ml-2 text-sm font-medium text-gray-500"> + <i18n.Translate>previous</i18n.Translate>{" "} + {!previous ? "-" : previous} + </small> + </div> + {!!rate && ( + <span + data-negative={negative} + class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 text-green-800 data-[negative=true]:bg-red-100 px-2 py-1 text-xs font-medium data-[negative=true]:text-red-700 whitespace-pre" + > + {negative ? ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75" + /> + </svg> + ) : ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75" + /> + </svg> + )} + + {negative ? ( + <span class="sr-only"> + <i18n.Translate>Decreased by</i18n.Translate> + </span> + ) : ( + <span class="sr-only"> + <i18n.Translate>Increased by</i18n.Translate> + </span> + )} + {rateStr} + </span> + )} + </div> + </dd> + </Fragment> + ); +} + +export type Timeframe = { start: AbsoluteTime; end: AbsoluteTime }; + +export function getTimeframesForDate( + time: Date, + timeframe: TalerCorebankApi.MonitorTimeframeParam, +): { + current: Timeframe; + previous: Timeframe; +} { + switch (timeframe) { + case TalerCorebankApi.MonitorTimeframeParam.hour: { + const [high, middle, low] = [0, 1, 2].map((timeIndex) => + AbsoluteTime.fromMilliseconds( + sub(time, { hours: timeIndex }).getTime(), + ), + ); + return { + current: { start: middle, end: high }, + previous: { start: low, end: middle }, + }; + } + case TalerCorebankApi.MonitorTimeframeParam.day: { + const [high, middle, low] = [0, 1, 2].map((timeIndex) => + AbsoluteTime.fromMilliseconds(sub(time, { days: timeIndex }).getTime()), + ); + return { + current: { start: middle, end: high }, + previous: { start: low, end: middle }, + }; + } + case TalerCorebankApi.MonitorTimeframeParam.month: { + const [high, middle, low] = [0, 1, 2].map((timeIndex) => + AbsoluteTime.fromMilliseconds( + sub(time, { months: timeIndex }).getTime(), + ), + ); + return { + current: { start: middle, end: high }, + previous: { start: low, end: middle }, + }; + } + + case TalerCorebankApi.MonitorTimeframeParam.year: { + const [high, middle, low] = [0, 1, 2].map((timeIndex) => + AbsoluteTime.fromMilliseconds( + sub(time, { years: timeIndex }).getTime(), + ), + ); + return { + current: { start: middle, end: high }, + previous: { start: low, end: middle }, + }; + } + case TalerCorebankApi.MonitorTimeframeParam.decade: { + const [high, middle, low] = [0, 1, 2].map((timeIndex) => + AbsoluteTime.fromMilliseconds( + sub(time, { years: timeIndex * 10 }).getTime(), + ), + ); + return { + current: { start: middle, end: high }, + previous: { start: low, end: middle }, + }; + } + default: + assertUnreachable(timeframe); + } +} + +function getDateStringForTimeframe( + date: AbsoluteTime, + timeframe: TalerCorebankApi.MonitorTimeframeParam, + locale: Locale, +): string { + if (date.t_ms === "never") return "--"; + switch (timeframe) { + case TalerCorebankApi.MonitorTimeframeParam.hour: + return `${format(date.t_ms, "HH:00", { locale })}hs`; + case TalerCorebankApi.MonitorTimeframeParam.day: + return format(date.t_ms, "EEEE", { locale }); + case TalerCorebankApi.MonitorTimeframeParam.month: + return format(date.t_ms, "MMMM", { locale }); + case TalerCorebankApi.MonitorTimeframeParam.year: + return format(date.t_ms, "yyyy", { locale }); + case TalerCorebankApi.MonitorTimeframeParam.decade: + return format(date.t_ms, "yyyy", { locale }); + } + assertUnreachable(timeframe); +} + +function SelectTimeframe({ + timeframe, + setTimeframe, +}: { + timeframe: TalerCorebankApi.MonitorTimeframeParam; + setTimeframe: (t: TalerCorebankApi.MonitorTimeframeParam) => void; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <Fragment> + <div class="sm:hidden"> + <label for="tabs" class="sr-only"> + <i18n.Translate>Select a section</i18n.Translate> + </label> + <select + id="tabs" + name="tabs" + class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" + onChange={(e) => { + setTimeframe( + parseInt( + e.currentTarget.value, + 10, + ) as TalerCorebankApi.MonitorTimeframeParam, + ); + }} + > + <option + value={TalerCorebankApi.MonitorTimeframeParam.hour} + selected={timeframe == TalerCorebankApi.MonitorTimeframeParam.hour} + > + <i18n.Translate>Last hour</i18n.Translate> + </option> + <option + value={TalerCorebankApi.MonitorTimeframeParam.day} + selected={timeframe == TalerCorebankApi.MonitorTimeframeParam.day} + > + <i18n.Translate>Previous day</i18n.Translate> + </option> + <option + value={TalerCorebankApi.MonitorTimeframeParam.month} + selected={timeframe == TalerCorebankApi.MonitorTimeframeParam.month} + > + <i18n.Translate>Last month</i18n.Translate> + </option> + <option + value={TalerCorebankApi.MonitorTimeframeParam.year} + selected={timeframe == TalerCorebankApi.MonitorTimeframeParam.year} + > + <i18n.Translate>Last year</i18n.Translate> + </option> + </select> + </div> + <div class="hidden sm:block"> + {/* FIXME: This should be LINKS */} + <nav + class="isolate flex divide-x divide-gray-200 rounded-lg shadow" + aria-label="Tabs" + > + <button + type="button" + name="set last hour" + onClick={(e) => { + e.preventDefault(); + setTimeframe(TalerCorebankApi.MonitorTimeframeParam.hour); + }} + data-selected={ + timeframe == TalerCorebankApi.MonitorTimeframeParam.hour + } + class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Last hour</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + timeframe == TalerCorebankApi.MonitorTimeframeParam.hour + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + <button + type="button" + name="set previous day" + onClick={(e) => { + e.preventDefault(); + setTimeframe(TalerCorebankApi.MonitorTimeframeParam.day); + }} + data-selected={ + timeframe == TalerCorebankApi.MonitorTimeframeParam.day + } + class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Previous day</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + timeframe == TalerCorebankApi.MonitorTimeframeParam.day + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + <button + type="button" + name="set last month" + onClick={(e) => { + e.preventDefault(); + setTimeframe(TalerCorebankApi.MonitorTimeframeParam.month); + }} + data-selected={ + timeframe == TalerCorebankApi.MonitorTimeframeParam.month + } + class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Last month</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + timeframe == TalerCorebankApi.MonitorTimeframeParam.month + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + <button + type="button" + name="set last year" + onClick={(e) => { + e.preventDefault(); + setTimeframe(TalerCorebankApi.MonitorTimeframeParam.year); + }} + data-selected={ + timeframe == TalerCorebankApi.MonitorTimeframeParam.year + } + class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Last Year</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + timeframe == TalerCorebankApi.MonitorTimeframeParam.year + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + </nav> + </div> + </Fragment> + ); +} diff --git a/packages/aml-backoffice-ui/src/pages/Measures.tsx b/packages/aml-backoffice-ui/src/pages/Measures.tsx @@ -28,16 +28,13 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; -import { useServerMeasures } from "../hooks/account.js"; import { CurrentMeasureTable, MeasureInfo } from "./MeasuresTable.js"; import { Officer } from "./Officer.js"; +import { useServerMeasures } from "../hooks/server-info.js"; export function Measures({}: {}) { const { i18n } = useTranslationContext(); - // const { forms } = useUiFormsContext(); - - // const allForms = [...forms, ...preloadedForms(i18n)]; const measures = useServerMeasures(); if (!measures) { diff --git a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx @@ -34,13 +34,15 @@ import { Properties } from "./Properties.js"; import { Rules } from "./Rules.js"; import { Measures } from "./Measures.js"; import { Justification } from "./Justification.js"; +import { Summary } from "./Summary.js"; export type WizardSteps = | "rules" // define the limits | "measures" // define a new form/challenge | "properties" // define account information | "events" // define events to trigger - | "justification"; // finalize, investigate?; + | "justification" // finalize, investigate?; + | "summary"; const STEPS_ORDER: WizardSteps[] = [ "rules", @@ -48,6 +50,7 @@ const STEPS_ORDER: WizardSteps[] = [ "properties", "events", "justification", + "summary", ]; const STEPS_ORDER_MAP = STEPS_ORDER.reduce( @@ -108,6 +111,8 @@ export function AmlDecisionRequestWizard({ return <Measures />; case "justification": return <Justification />; + case "summary": + return <Summary />; } assertUnreachable(stepOrDefault); })(); @@ -178,6 +183,11 @@ function WizardSteps({ description: i18n.str`Add information about the account.`, isCompleted: isPropertiesCompleted, }, + summary: { + label: i18n.str`Summary`, + description: i18n.str`Review and send.`, + isCompleted: () => false, + }, }; return ( <div class="lg:border-b lg:border-t lg:border-gray-200"> diff --git a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx @@ -1,19 +1,23 @@ import { - useTranslationContext, - useExchangeApiContext, - useForm, + AmlSpaDialect, + assertUnreachable, + MeasureInformation, +} from "@gnu-taler/taler-util"; +import { + FormDesign, FormUI, - TalerFormAttributes, - UIHandlerId, InternationalizationAPI, - UIFormElementConfig, - FormDesign, onComponentUnload, + UIFormElementConfig, + UIHandlerId, + useExchangeApiContext, + useForm, + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; import { usePreferences } from "../../hooks/preferences.js"; -import { MeasureInformation } from "@gnu-taler/taler-util"; +import { AML_EVENTS_INFO, AmlEventsName } from "./aml-events.js"; /** * Trigger additional events @@ -26,21 +30,36 @@ export function Events({}: {}): VNode { const [pref] = usePreferences(); const { config } = useExchangeApiContext(); - const calculatedProps = { - ...(request.properties ?? {}), - ...(request.custom_properties ?? {}), - }; + const dialect = + (pref.testingDialect ? undefined : config.config.aml_spa_dialect) ?? + AmlSpaDialect.TESTING; - const calculatedEvents = eventsByDialect( - i18n, - pref.testingDialect ? "testing" : config.config.aml_spa_dialect, - calculatedProps, + const calculatedEvents = Object.entries(AML_EVENTS_INFO).reduce( + (prev, [name, info]) => { + const field = { + id: name as UIHandlerId, + type: "toggle", + required: true, + label: labelForEvent(name as AmlEventsName, i18n), + } satisfies UIFormElementConfig; + + if (info.shouldBeTriggered(request, dialect)) { + prev.on.push(field); + } else { + prev.off.push(field); + } + return prev; + }, + { on: [], off: [] } as { + on: UIFormElementConfig[]; + off: UIFormElementConfig[]; + }, ); - const design = eventsForm(i18n, calculatedEvents); + const design = formDesign(i18n, calculatedEvents.on); - const form = useForm<EventsForm>(design, { - inhibit: calculatedEvents.reduce( + const form = useForm<FormType>(design, { + inhibit: calculatedEvents.on.reduce( (prev, cur) => { if (cur.type !== "toggle") return prev; const isInhibit = @@ -49,7 +68,7 @@ export function Events({}: {}): VNode { prev[cur.id] = isInhibit; return prev; }, - {} as EventsForm["inhibit"], + {} as FormType["inhibit"], ), trigger: !request.custom_events ? [] @@ -75,28 +94,28 @@ export function Events({}: {}): VNode { ); } -export type EventsForm = { +type FormType = { trigger: { name: string }[]; inhibit: { [name: string]: boolean }; }; -export const eventsForm = ( +const formDesign = ( i18n: InternationalizationAPI, - defaultEvents: UIFormElementConfig[], + inhibitEvents: UIFormElementConfig[], ): FormDesign<MeasureInformation> => ({ type: "double-column", sections: [ { title: i18n.str`Inhibit default events`, - description: i18n.str`Use this form to prevent events to be triggered by the current status.`, - fields: !defaultEvents.length + description: i18n.str`Here you can prevent events to be triggered by the current status.`, + fields: !inhibitEvents.length ? [ { type: "caption", label: i18n.str`No default events calculated.`, }, ] - : defaultEvents.map((f) => + : inhibitEvents.map((f) => "id" in f ? { ...f, id: ("inhibit." + f.id) as UIHandlerId } : f, ), }, @@ -120,89 +139,44 @@ export const eventsForm = ( ], }, ], - // fields: [ - // { - // id: "trigger" as UIHandlerId, - // type: "array", - // labelFieldId: "name" as UIHandlerId, - // label: i18n.str`Trigger`, - // fields: [], - // }, - // { - // id: "inhibit" as UIHandlerId, - // type: "array", - // labelFieldId: "name" as UIHandlerId, - // label: i18n.str`Inhibit`, - // fields: [], - // }, - // ], }); -export function eventsByDialect( - i18n: InternationalizationAPI, - dialect: string | undefined, - properties: object, -): UIFormElementConfig[] { - if (!dialect) return []; - const result: UIFormElementConfig[] = []; - switch (dialect) { - case "testing": { - const props = properties as TalerFormAttributes.AccountProperties_TOPS; - if (props.ACCOUNT_FROZEN) { - result.push({ - id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is froozen?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - if (props.ACCOUNT_SANCTIONED) { - result.push({ - id: "ACCOUNT_SANCTIONED" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is sacntioned?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - if (props.ACCOUNT_HIGH_RISK) { - result.push({ - id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is high risk?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - break; - } - case "gls": { - const props = properties as TalerFormAttributes.AccountProperties_TOPS; - if (props.ACCOUNT_FROZEN) { - result.push({ - id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is frozen?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - break; - } - case "tops": { - const props = properties as TalerFormAttributes.AccountProperties_TOPS; - if (props.ACCOUNT_HIGH_RISK) { - result.push({ - id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is high risk?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - break; +function labelForEvent(event: AmlEventsName, i18n: InternationalizationAPI) { + switch (event) { + case AmlEventsName.ACCOUNT_FROZEN: + return i18n.str`new account frozen`; + case AmlEventsName.ACCOUNT_PEP: + return i18n.str`new exposed person`; + case AmlEventsName.TEST_EVENT_KEY_1: + return i18n.str`TEST_EVENT_KEY_1`; + case AmlEventsName.TEST_EVENT_KEY_2: + return i18n.str`TEST_EVENT_KEY_2`; + case AmlEventsName.TEST_EVENT_KEY_3: + return i18n.str`TEST_EVENT_KEY_3`; + case AmlEventsName.TEST_EVENT_KEY_4: + return i18n.str`TEST_EVENT_KEY_4`; + case AmlEventsName.TEST_EVENT_KEY_5: + return i18n.str`TEST_EVENT_KEY_5`; + case AmlEventsName.TEST_EVENT_KEY_6: + return i18n.str`TEST_EVENT_KEY_6`; + case AmlEventsName.TEST_EVENT_KEY_7: + return i18n.str`TEST_EVENT_KEY_7`; + case AmlEventsName.TEST_EVENT_KEY_8: + return i18n.str`TEST_EVENT_KEY_8`; + case AmlEventsName.TEST_EVENT_KEY_9: + return i18n.str`TEST_EVENT_KEY_9`; + case AmlEventsName.TEST_EVENT_KEY_10: + return i18n.str`TEST_EVENT_KEY_10`; + case AmlEventsName.TEST_EVENT_KEY_11: + return i18n.str`TEST_EVENT_KEY_11`; + case AmlEventsName.TEST_EVENT_KEY_12: + return i18n.str`TEST_EVENT_KEY_12`; + case AmlEventsName.TEST_EVENT_KEY_13: + return i18n.str`TEST_EVENT_KEY_13`; + case AmlEventsName.TEST_EVENT_KEY_14: + return i18n.str`TEST_EVENT_KEY_14`; + default: { + assertUnreachable(event); } } - return result; } diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx @@ -1,6 +1,27 @@ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + FormDesign, + FormUI, + InternationalizationAPI, + onComponentUnload, + UIHandlerId, + useForm, + useTranslationContext, +} 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"; /** * Mark for further investigation and explain decision @@ -9,6 +30,98 @@ import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; */ export function Justification({}: {}): VNode { const { i18n } = useTranslationContext(); - const [request] = useCurrentDecisionRequest(); - return <div> not yet impltemented: justification and investigation</div>; + const [request, _, updateRequest] = useCurrentDecisionRequest(); + const measures = useServerMeasures(); + const measureList = + !measures || measures instanceof TalerError || measures.type === "fail" + ? [] + : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi })); + const design = formDesign(i18n, measureList); + + const expMeasres: MeasurePath[] = !request.onExpire_measures + ? [] + : deserializeMeasures(request.onExpire_measures); + + const form = useForm<FormType>(design, { + investigate: request.keep_investigating, + justification: request.justification, + expiration: request.deadline, + measures: expMeasres, + }); + + onComponentUnload(() => { + updateRequest({ + ...request, + keep_investigating: !!form.status.result.investigate, + justification: form.status.result.justification ?? "", + onExpire_measures: serializeMeasures( + (form.status.result.measures ?? []) as MeasurePath[], + ), + + deadline: + (form.status.result.expiration as AbsoluteTime) ?? AbsoluteTime.never(), + // onExpire_measures, + }); + }); + + return ( + <div> + <FormUI design={design} handler={form.handler} /> + </div> + ); } + +type FormType = { + justification: string; + investigate: boolean; + expiration: AbsoluteTime; + measures: MeasurePath[]; +}; + +const formDesign = ( + i18n: InternationalizationAPI, + mi: (MeasureInformation & { id: string })[], +): FormDesign<FormType> => ({ + type: "single-column", + fields: [ + { + id: "justification" as UIHandlerId, + type: "textArea", + label: i18n.str`Justification`, + }, + { + id: "investigate" as UIHandlerId, + type: "toggle", + label: i18n.str`Keep investigation?`, + }, + { + type: "choiceHorizontal", + label: i18n.str`Expiration`, + id: "expiration" as UIHandlerId, + 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, + }, + ], + }, + { + id: "expiration" as UIHandlerId, + type: "absoluteTimeText", + placeholder: "dd/MM/yyyy", + pattern: "dd/MM/yyyy", + label: i18n.str`Expiration`, + }, + measureArrayField(i18n, mi), + ], +}); diff --git a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx @@ -11,9 +11,9 @@ import { RecursivePartial, } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; -import { useServerMeasures } from "../../hooks/account.js"; 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[]>, @@ -47,12 +47,12 @@ export function Measures({}: {}): VNode { ? [] : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi })); - const initValue: MeasureForm = !request.new_measures + const initValue: FormType = !request.new_measures ? { paths: [] } : { paths: deserializeMeasures(request.new_measures) }; - const design = measureForm(i18n, measureList); - const form = useForm<MeasureForm>(design, initValue); + const design = formDesign(i18n, measureList); + const form = useForm<FormType>(design, initValue); onComponentUnload(() => { const r = !form.status.result.paths @@ -77,9 +77,9 @@ export function Measures({}: {}): VNode { ); } -type MeasurePath = { steps: string[] }; +export type MeasurePath = { steps: string[] }; -type MeasureForm = { +type FormType = { paths: MeasurePath[]; }; @@ -110,10 +110,10 @@ export function measureArrayField( }; } -function measureForm( +function formDesign( i18n: InternationalizationAPI, mi: (MeasureInformation & { id: string })[], -): FormDesign<MeasureForm> { +): FormDesign<FormType> { return { type: "single-column", fields: [measureArrayField(i18n, mi)], diff --git a/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx b/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx @@ -13,6 +13,7 @@ import { import { h, VNode } from "preact"; import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; import { usePreferences } from "../../hooks/preferences.js"; +import { AmlSpaDialect } from "@gnu-taler/taler-util"; /** * Update account properites @@ -115,7 +116,7 @@ export function propertiesByDialect( ): UIFormElementConfig[] { if (!dialect) return []; switch (dialect) { - case "testing": { + case AmlSpaDialect.TESTING: { return [ { id: "ACCOUNT_PEP" satisfies keyof TalerFormAttributes.AccountProperties_Testing as UIHandlerId, @@ -124,9 +125,23 @@ export function propertiesByDialect( type: "toggle", required: true, }, + { + id: "ACCOUNT_BUSINESS_DOMAIN" satisfies keyof TalerFormAttributes.AccountProperties_Testing as UIHandlerId, + label: i18n.str`Business domain`, + // gana_type: "Text", + type: "text", + required: true, + }, + { + id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_Testing as UIHandlerId, + label: i18n.str`Is frozen?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }, ]; } - case "gls": { + case AmlSpaDialect.GLS: { return [ { id: "ACCOUNT_REPORTED" satisfies keyof TalerFormAttributes.AccountProperties_GLS as UIHandlerId, @@ -137,7 +152,7 @@ export function propertiesByDialect( }, ]; } - case "tops": { + case AmlSpaDialect.TOPS: { return [ { id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx @@ -20,12 +20,11 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useServerMeasures } from "../../hooks/account.js"; 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"; /** * Defined new limits for the account @@ -55,6 +54,12 @@ export function Rules({ account }: { account?: string }): VNode { ? undefined : activeDecision.body; + onComponentUnload(() => { + if (!request.rules) { + updateRequest("rules", []); + } + }); + function addNewRule(nr: FormType) { const result = !request.rules ? [] : [...request.rules]; result.push({ @@ -132,13 +137,6 @@ type FormType = { paths: { steps: Array<string> }[]; }; -// operation_type: LimitOperationType; -// threshold: AmountString; -// timeframe: RelativeTime; -// measures: string[]; -// display_priority: Integer; -// exposed?: boolean; -// is_and_combinator?: boolean; function labelForOperationType( op: LimitOperationType, i18n: InternationalizationAPI, diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx @@ -0,0 +1,23 @@ +import { + FormDesign, + FormUI, + InternationalizationAPI, + onComponentUnload, + UIHandlerId, + useForm, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; + +/** + * Mark for further investigation and explain decision + * @param param0 + * @returns + */ +export function Summary({}: {}): VNode { + const { i18n } = useTranslationContext(); + const [request] = useCurrentDecisionRequest(); + + return <div>summary</div>; +} diff --git a/packages/aml-backoffice-ui/src/pages/decision/aml-events.ts b/packages/aml-backoffice-ui/src/pages/decision/aml-events.ts @@ -0,0 +1,133 @@ +import { AmlSpaDialect } from "@gnu-taler/taler-util"; +import { TalerFormAttributes } from "@gnu-taler/web-util/browser"; +import { DecisionRequest } from "../../hooks/decision-request.js"; + +export enum AmlEventsName { + ACCOUNT_FROZEN = "ACCOUNT_FROZEN", + ACCOUNT_PEP = "ACCOUNT_PEP", + TEST_EVENT_KEY_1 = "TEST_EVENT_KEY_1", + TEST_EVENT_KEY_2 = "TEST_EVENT_KEY_2", + TEST_EVENT_KEY_3 = "TEST_EVENT_KEY_3", + TEST_EVENT_KEY_4 = "TEST_EVENT_KEY_4", + TEST_EVENT_KEY_5 = "TEST_EVENT_KEY_5", + TEST_EVENT_KEY_6 = "TEST_EVENT_KEY_6", + TEST_EVENT_KEY_7 = "TEST_EVENT_KEY_7", + TEST_EVENT_KEY_8 = "TEST_EVENT_KEY_8", + TEST_EVENT_KEY_9 = "TEST_EVENT_KEY_9", + TEST_EVENT_KEY_10 = "TEST_EVENT_KEY_10", + TEST_EVENT_KEY_11 = "TEST_EVENT_KEY_11", + TEST_EVENT_KEY_12 = "TEST_EVENT_KEY_12", + TEST_EVENT_KEY_13 = "TEST_EVENT_KEY_13", + TEST_EVENT_KEY_14 = "TEST_EVENT_KEY_14", +} + +export type EventMapInfo = { + [name in AmlEventsName]: { + // fieldLabel: TranslatedString; + dialect: AmlSpaDialect[]; + shouldBeTriggered: (req: DecisionRequest, d: AmlSpaDialect) => boolean; + }; +}; + +export const AML_EVENTS_INFO: EventMapInfo = { + ACCOUNT_FROZEN: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + if (!req.properties) return false; + return !!(req.properties as TalerFormAttributes.AccountProperties_TOPS) + .ACCOUNT_FROZEN; + }, + }, + ACCOUNT_PEP: { + dialect: [AmlSpaDialect.GLS, AmlSpaDialect.TOPS, AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + if (!req.properties) return false; + return !!(req.properties as TalerFormAttributes.AccountProperties_TOPS) + .ACCOUNT_PEP; + }, + }, + TEST_EVENT_KEY_1: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_2: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_3: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_4: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_5: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_6: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_7: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_8: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_9: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_10: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_11: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_12: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_13: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, + TEST_EVENT_KEY_14: { + dialect: [AmlSpaDialect.TESTING], + shouldBeTriggered(req, dialect) { + return true; + }, + }, +}; diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts @@ -42,10 +42,10 @@ const useSWR = _useSWR as unknown as SWRHook; export type TransferCalculation = | { - debit: AmountJson; - credit: AmountJson; - beforeFee: AmountJson; - } + debit: AmountJson; + credit: AmountJson; + beforeFee: AmountJson; + } | "amount-is-too-small"; type EstimatorFunction = ( amount: AmountJson, @@ -112,7 +112,9 @@ export function useCashinEstimator(): ConversionEstimators { if (resp.detail) { throw TalerError.fromUncheckedDetail(resp.detail); } else { - throw TalerError.fromException(new Error("failed to get conversion cashin rate")) + throw TalerError.fromException( + new Error("failed to get conversion cashin rate"), + ); } } } @@ -141,7 +143,9 @@ export function useCashinEstimator(): ConversionEstimators { if (resp.detail) { throw TalerError.fromUncheckedDetail(resp.detail); } else { - throw TalerError.fromException(new Error("failed to get conversion cashin rate")) + throw TalerError.fromException( + new Error("failed to get conversion cashin rate"), + ); } } } @@ -178,7 +182,9 @@ export function useCashoutEstimator(): ConversionEstimators { if (resp.detail) { throw TalerError.fromUncheckedDetail(resp.detail); } else { - throw TalerError.fromException(new Error("failed to get conversion cashout rate")) + throw TalerError.fromException( + new Error("failed to get conversion cashout rate"), + ); } } } @@ -207,7 +213,9 @@ export function useCashoutEstimator(): ConversionEstimators { if (resp.detail) { throw TalerError.fromUncheckedDetail(resp.detail); } else { - throw TalerError.fromException(new Error("failed to get conversion cashout rate")) + throw TalerError.fromException( + new Error("failed to get conversion cashout rate"), + ); } } } @@ -484,6 +492,7 @@ export function useLastMonitorInfo( lib: { bank: api }, } = useBankCoreApiContext(); const { state: credentials } = useSessionState(); + const token = credentials.status !== "loggedIn" ? undefined : credentials.token; diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx @@ -187,7 +187,7 @@ export function getTimeframesForDate( sub(time, { days: 1 }).getTime(), ), previous: AbsoluteTime.fromMilliseconds( - sub(time, { days: 4 }).getTime(), + sub(time, { days: 2 }).getTime(), ), }; case TalerCorebankApi.MonitorTimeframeParam.month: @@ -241,11 +241,10 @@ function Metrics({ if (resp instanceof TalerError) { return <ErrorLoadingWithDebug error={resp} />; } - if (!respInfo) return <Fragment />; - if (respInfo instanceof TalerError) { + if (respInfo && respInfo instanceof TalerError) { return <ErrorLoadingWithDebug error={respInfo} />; } - if (respInfo.type === "fail") { + if (respInfo && respInfo.type === "fail") { switch (respInfo.case) { case HttpStatusCode.NotImplemented: { return ( @@ -483,7 +482,8 @@ function Metrics({ </h1> </div> <dl class="mt-5 grid grid-cols-1 md:grid-cols-2 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0"> - {resp.current.body.type !== "with-conversions" || + {!respInfo || + resp.current.body.type !== "with-conversions" || resp.previous.body.type !== "with-conversions" ? undefined : ( <Fragment> <div class="px-4 py-5 sm:p-6"> @@ -496,7 +496,7 @@ function Metrics({ </i18n.Translate> </div> </dt> - <MetricValue + <MetricValueAmount current={resp.current.body.cashinFiatVolume} previous={resp.previous.body.cashinFiatVolume} spec={respInfo.body.fiat_currency_specification} @@ -512,7 +512,7 @@ function Metrics({ account. </i18n.Translate> </div> - <MetricValue + <MetricValueAmount current={resp.current.body.cashoutFiatVolume} previous={resp.previous.body.cashoutFiatVolume} spec={respInfo.body.fiat_currency_specification} @@ -529,7 +529,7 @@ function Metrics({ </i18n.Translate> </div> </dt> - <MetricValue + <MetricValueAmount current={resp.current.body.talerInVolume} previous={resp.previous.body.talerInVolume} spec={config.currency_specification} @@ -544,12 +544,40 @@ function Metrics({ </i18n.Translate> </div> </dt> - <MetricValue + <MetricValueAmount current={resp.current.body.talerOutVolume} previous={resp.previous.body.talerOutVolume} spec={config.currency_specification} /> </div> + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Payin</i18n.Translate> + <div class="text-xs text-gray-500"> + <i18n.Translate> + Transferred from an account to a Taler exchange. + </i18n.Translate> + </div> + </dt> + <MetricValueNumber + current={resp.current.body.talerInCount} + previous={resp.previous.body.talerInCount} + /> + </div> + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Payout</i18n.Translate> + <div class="text-xs text-gray-500"> + <i18n.Translate> + Transferred from a Taler exchange to another account. + </i18n.Translate> + </div> + </dt> + <MetricValueNumber + current={resp.current.body.talerOutCount} + previous={resp.previous.body.talerOutCount} + /> + </div> </dl> <div class="flex justify-end mt-4"> <a @@ -564,7 +592,7 @@ function Metrics({ ); } -function MetricValue({ +function MetricValueAmount({ current, previous, spec, @@ -678,3 +706,93 @@ function MetricValue({ </Fragment> ); } + +function MetricValueNumber({ + current, + previous, +}: { + current: number | undefined; + previous: number | undefined; +}): VNode { + const { i18n } = useTranslationContext(); + + const cmp = current && previous ? (current < previous ? -1 : 1) : 0; + + const rate = + !current || Number.isNaN(current) || !previous || Number.isNaN(previous) + ? 0 + : cmp === -1 + ? 1 - Math.round(current) / Math.round(previous) + : cmp === 1 + ? Math.round(current) / Math.round(previous) - 1 + : 0; + + const negative = cmp === 0 ? undefined : cmp === -1; + const rateStr = `${(Math.abs(rate) * 100).toFixed(2)}%`; + return ( + <Fragment> + <dd class="mt-1 block "> + <div class="flex justify-start text-2xl items-baseline font-semibold text-indigo-600"> + {!current ? "-" : current} + </div> + <div class="flex flex-col"> + <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600"> + <small class="ml-2 text-sm font-medium text-gray-500"> + <i18n.Translate>previous</i18n.Translate>{" "} + {!previous ? "-" : previous} + </small> + </div> + {!!rate && ( + <span + data-negative={negative} + class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 text-green-800 data-[negative=true]:bg-red-100 px-2 py-1 text-xs font-medium data-[negative=true]:text-red-700 whitespace-pre" + > + {negative ? ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75" + /> + </svg> + ) : ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75" + /> + </svg> + )} + + {negative ? ( + <span class="sr-only"> + <i18n.Translate>Decreased by</i18n.Translate> + </span> + ) : ( + <span class="sr-only"> + <i18n.Translate>Increased by</i18n.Translate> + </span> + )} + {rateStr} + </span> + )} + </div> + </dd> + </Fragment> + ); +} diff --git a/packages/taler-util/src/http-client/exchange.ts b/packages/taler-util/src/http-client/exchange.ts @@ -81,6 +81,7 @@ import { TalerError } from "../errors.js"; import { TalerErrorCode } from "../taler-error-codes.js"; import { codecForEmptyObject } from "../types-taler-wallet.js"; import { canonicalJson } from "../helpers.js"; +import { AbsoluteTime } from "../time.js"; export type TalerExchangeResultByMethod< prop extends keyof TalerExchangeHttpClient, @@ -861,17 +862,17 @@ export class TalerExchangeHttpClient { auth: OfficerAccount, name: string, filter: { - since?: Date; - until?: Date; + since?: AbsoluteTime; + until?: AbsoluteTime; } = {}, ) { const url = new URL(`aml/${auth.id}/kyc-statistics/${name}`, this.baseUrl); - if (filter.since !== undefined) { - url.searchParams.set("start_date", String(filter.since.getTime())); + if (filter.since !== undefined && filter.since.t_ms !== "never") { + url.searchParams.set("start_date", String(filter.since.t_ms)); } - if (filter.until !== undefined) { - url.searchParams.set("end_date", String(filter.until.getTime())); + if (filter.until !== undefined && filter.until.t_ms !== "never") { + url.searchParams.set("end_date", String(filter.until.t_ms)); } const resp = await this.httpLib.fetch(url.href, { diff --git a/packages/taler-util/src/types-taler-exchange.ts b/packages/taler-util/src/types-taler-exchange.ts @@ -1610,6 +1610,12 @@ export type AmlDecisionRequestWithoutSignature = Omit< "officer_sig" >; +export enum AmlSpaDialect { + TOPS = "tops", + GLS = "gls", + TESTING = "testing", +} + export interface ExchangeVersionResponse { // libtool-style representation of the Exchange protocol version, see // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning @@ -1638,7 +1644,7 @@ export interface ExchangeVersionResponse { // to show and sets of properties/events to trigger in // AML decisions. // @since protocol **v24**. - aml_spa_dialect?: string; + aml_spa_dialect?: AmlSpaDialect; } export interface WireAccount { @@ -1780,16 +1786,6 @@ export interface AccountKycStatus { limits?: AccountLimit[]; } -export type LimitOperationType2 = - | "WITHDRAW" - | "DEPOSIT" - | "MERGE" - | "AGGREGATE" - | "BALANCE" - | "REFUND" - | "CLOSE" - | "TRANSACTION"; - export enum LimitOperationType { withdraw = "WITHDRAW", deposit = "DEPOSIT", @@ -2446,6 +2442,12 @@ interface CSDenominationKey { cs_public_key: Cs25519Point; } +export const codecForAmlSpaDialect = codecForEither( + codecForConstString(AmlSpaDialect.GLS), + codecForConstString(AmlSpaDialect.TOPS), + codecForConstString(AmlSpaDialect.TESTING), +); + export const codecForExchangeConfig = (): Codec<ExchangeVersionResponse> => buildCodecForObject<ExchangeVersionResponse>() .property("version", codecForString()) @@ -2454,7 +2456,7 @@ export const codecForExchangeConfig = (): Codec<ExchangeVersionResponse> => .property("currency", codecForString()) .property("currency_specification", codecForCurrencySpecificiation()) .property("supported_kyc_requirements", codecForList(codecForString())) - .property("aml_spa_dialect", codecOptional(codecForString())) + .property("aml_spa_dialect", codecOptional(codecForAmlSpaDialect)) .deprecatedProperty("shopping_url") .deprecatedProperty("wallet_balance_limit_without_kyc") .build("TalerExchangeApi.ExchangeVersionResponse");