diff options
38 files changed, 1137 insertions, 803 deletions
diff --git a/packages/aml-backoffice-ui/src/App.tsx b/packages/aml-backoffice-ui/src/App.tsx index 52c86c273..d461934c0 100644 --- a/packages/aml-backoffice-ui/src/App.tsx +++ b/packages/aml-backoffice-ui/src/App.tsx @@ -4,7 +4,7 @@ import { ExchangeAmlFrame } from "./Dashboard.js"; import "./scss/main.css"; import { ExchangeApiProvider } from "./context/config.js"; import { getInitialBackendBaseURL } from "./hooks/useBackend.js"; -import { Router } from "./route.js"; +import { HashPathProvider, Router } from "./route.js"; import { Pages } from "./pages.js"; const pageList = Object.values(Pages); @@ -15,15 +15,17 @@ export function App(): VNode { return ( <TranslationProvider source={{}}> <ExchangeApiProvider baseUrl={baseUrl} frameOnError={ExchangeAmlFrame}> - <ExchangeAmlFrame> - <Router - pageList={pageList} - onNotFound={() => { - window.location.href = Pages.cases.url - return <div>not found</div>; - }} - /> - </ExchangeAmlFrame> + <HashPathProvider> + <ExchangeAmlFrame> + <Router + pageList={pageList} + onNotFound={() => { + window.location.href = Pages.cases.url + return <div>not found</div>; + }} + /> + </ExchangeAmlFrame> + </HashPathProvider> </ExchangeApiProvider> </TranslationProvider> ); diff --git a/packages/aml-backoffice-ui/src/Dashboard.tsx b/packages/aml-backoffice-ui/src/Dashboard.tsx index b813f83d5..2d75de660 100644 --- a/packages/aml-backoffice-ui/src/Dashboard.tsx +++ b/packages/aml-backoffice-ui/src/Dashboard.tsx @@ -5,7 +5,7 @@ import { useEffect, useErrorBoundary } from "preact/hooks"; import { useOfficer } from "./hooks/useOfficer.js"; import { getAllBooleanSettings, getLabelForSetting, useSettings } from "./hooks/useSettings.js"; import { Pages } from "./pages.js"; -import { PageEntry, useChangeLocation, useCurrentLocation } from "./route.js"; +import { PageEntry, useChangeLocation } from "./route.js"; import { uiSettings } from "./settings.js"; function classNames(...classes: string[]) { @@ -73,8 +73,17 @@ const versionText = VERSION * 4.- tooltip are not placed correctly: the arrow should point the question mark * and the text area should be bigger * - * 5.- date field should have the calendar icon clickable so the user can select date without - * writing text with the correct format + */ + +/** + * check this fields + * + * Signature of Contracting partner, 902_9e + * Currency and amount of deposited assets, 902_5e + * Signature on declaration of trust, 902.13e + * also fundations + * also life insurance + * */ export function ExchangeAmlFrame({ @@ -140,17 +149,17 @@ export function ExchangeAmlFrame({ <GlobalNotificationsBanner /> - <main class="-mt-32 flex grow "> + <div class="-mt-32 flex grow "> {officer.state !== "ready" ? undefined : <Navigation /> } <div class="flex mx-auto my-4"> - <div class="rounded-lg bg-white px-5 py-6 shadow sm:px-6"> + <main class="rounded-lg bg-white px-5 py-6 shadow"> {children} - </div> + </main> </div> - </main> + </div> <Footer testingUrl={localStorage.getItem("exchange-base-url") ?? undefined} @@ -169,7 +178,7 @@ function Navigation(): VNode { ] const location = useChangeLocation(); return ( - <div class="hidden sm:block w-48 min-w-min bg-indigo-600 divide-y rounded-r-lg divide-cyan-800 overflow-y-auto overflow-x-clip"> + <div class="hidden sm:block min-w-min bg-indigo-600 divide-y rounded-r-lg divide-cyan-800 overflow-y-auto overflow-x-clip"> <nav class="flex flex-1 flex-col mx-4 mt-4 mb-2"> <ul role="list" class="flex flex-1 flex-col gap-y-7"> @@ -179,7 +188,7 @@ function Navigation(): VNode { return <li> <a href={p.url} data-selected={location == p.url} - class="data-[selected=true]:bg-indigo-700 data-[selected=true]:text-white text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"> + class="data-[selected=true]:bg-indigo-700 pr-4 data-[selected=true]:text-white text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"> {p.Icon && <p.Icon />} <span class="hidden md:inline"> {p.name} diff --git a/packages/aml-backoffice-ui/src/NiceForm.tsx b/packages/aml-backoffice-ui/src/NiceForm.tsx index 4fc0ea89f..a78036a6b 100644 --- a/packages/aml-backoffice-ui/src/NiceForm.tsx +++ b/packages/aml-backoffice-ui/src/NiceForm.tsx @@ -10,11 +10,13 @@ export function NiceForm<T extends object>({ form, onSubmit, children, + readOnly, }: { children?: ComponentChildren; initial: Partial<T>; onSubmit?: (v: Partial<T>) => void; form: FlexibleForm<T>; + readOnly?: boolean; onUpdate?: (d: Partial<T>) => void; }) { return ( @@ -22,6 +24,7 @@ export function NiceForm<T extends object>({ initialValue={initial} onUpdate={onUpdate} onSubmit={onSubmit} + readOnly={readOnly} computeFormState={form.behavior} > <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> diff --git a/packages/aml-backoffice-ui/src/context/config.ts b/packages/aml-backoffice-ui/src/context/config.ts index 2866717de..2df7ff40d 100644 --- a/packages/aml-backoffice-ui/src/context/config.ts +++ b/packages/aml-backoffice-ui/src/context/config.ts @@ -35,6 +35,14 @@ const Context = createContext<Type>(undefined as any); export const useExchangeApiContext = (): Type => useContext(Context); +export function ExchangeApiContextTesting({ config, children }: { config: TalerExchangeApi.ExchangeVersionResponse, children?: ComponentChildren; }): VNode { + return h(Context.Provider, { + value: { url: new URL("http://testing"), config, api: null as any }, + children + } + ) +} + export type ConfigResult = undefined | { type: "ok", config: TalerExchangeApi.ExchangeVersionResponse } | { type: "incompatible", result: TalerExchangeApi.ExchangeVersionResponse, supported: string } diff --git a/packages/aml-backoffice-ui/src/forms/902_11e.ts b/packages/aml-backoffice-ui/src/forms/902_11e.ts index 5507c72dc..a604b560e 100644 --- a/packages/aml-backoffice-ui/src/forms/902_11e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_11e.ts @@ -1,10 +1,10 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { State } from "../pages/AntiMoneyLaunderingForm.js"; +import { BaseForm } from "../pages/AntiMoneyLaunderingForm.js"; import { FlexibleForm } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; -export const v1 = (current: State): FlexibleForm<Form902_11.Form> => ({ +export const v1 = (current: BaseForm): FlexibleForm<Form902_11.Form> => ({ versionId: "2023-05-15", design: [ { @@ -103,15 +103,6 @@ export const v1 = (current: State): FlexibleForm<Form902_11.Form> => ({ ], }, }, - { - type: "date", - props: { - name: "when", - pattern: "dd/MM/yyyy", - label: "Date" as TranslatedString, - help: "format 'dd/MM/yyyy'" as TranslatedString, - }, - }, ], }, resolutionSection(current), @@ -125,9 +116,6 @@ export const v1 = (current: State): FlexibleForm<Form902_11.Form> => ({ v.declares !== "controlling-in-other-ways" && v.declares !== "managing-director", }, - when: { - disabled: true, - }, }; }, }); @@ -138,7 +126,7 @@ namespace Form902_11 { firstName: string; address: string; } - export interface Form extends Simplest.WithResolution { + export interface Form extends BaseForm { contractingPartner: string; declares: "25-or-more" | "controlling-in-other-ways" | "managing-director"; person: Person[]; diff --git a/packages/aml-backoffice-ui/src/forms/902_12e.ts b/packages/aml-backoffice-ui/src/forms/902_12e.ts index ea95b494b..12e885e8f 100644 --- a/packages/aml-backoffice-ui/src/forms/902_12e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_12e.ts @@ -1,10 +1,10 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { State } from "../pages/AntiMoneyLaunderingForm.js"; +import { BaseForm } from "../pages/AntiMoneyLaunderingForm.js"; import { FlexibleForm } from "./index.js"; -import { Simplest, resolutionSection } from "./simplest.js"; +import { resolutionSection } from "./simplest.js"; -export const v1 = (current: State): FlexibleForm<Form902_12.Form> => ({ +export const v1 = (current: BaseForm): FlexibleForm<Form902_12.Form> => ({ versionId: "2023-05-15", design: [ { @@ -347,15 +347,6 @@ export const v1 = (current: State): FlexibleForm<Form902_12.Form> => ({ }, }, { - type: "date", - props: { - name: "when", - pattern: "dd/MM/yyyy", - label: "Date" as TranslatedString, - help: "format 'dd/MM/yyyy'" as TranslatedString, - }, - }, - { type: "text", props: { name: "signature", @@ -388,9 +379,6 @@ export const v1 = (current: State): FlexibleForm<Form902_12.Form> => ({ }; }), }, - when: { - disabled: true, - }, }; }, }); @@ -421,7 +409,7 @@ namespace Form902_12 { type Founder = WithRevoke<WithDeath<Person>>; type Beneficiary = WithClaim<Person>; - export interface Form extends Simplest.WithResolution { + export interface Form extends BaseForm { contractingPartner: string; knownAs: string; boardMember: string; diff --git a/packages/aml-backoffice-ui/src/forms/902_13e.ts b/packages/aml-backoffice-ui/src/forms/902_13e.ts index 666cf35d4..f03364de0 100644 --- a/packages/aml-backoffice-ui/src/forms/902_13e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_13e.ts @@ -1,10 +1,10 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { State } from "../pages/AntiMoneyLaunderingForm.js"; +import { BaseForm, } from "../pages/AntiMoneyLaunderingForm.js"; import { FlexibleForm } from "./index.js"; -import { Simplest, resolutionSection } from "./simplest.js"; +import { resolutionSection } from "./simplest.js"; -export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({ +export const v1 = (current: BaseForm): FlexibleForm<Form902_13.Form> => ({ versionId: "2023-05-15", design: [ { @@ -104,7 +104,7 @@ export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({ name: "dateOfBirth", label: "Date of birth" as TranslatedString, pattern: "dd/MM/yyyy", - help: "format 'dd/MM/yyyy'" as TranslatedString, + // help: "format 'dd/MM/yyyy'" as TranslatedString, }, }, { @@ -120,7 +120,8 @@ export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({ name: "dateOfDeath", label: "Date of death" as TranslatedString, pattern: "dd/MM/yyyy", - help: "if deceased. format 'dd/MM/yyyy'" as TranslatedString, + // help: "if deceased. format 'dd/MM/yyyy'" as TranslatedString, + help: "if deceased'" as TranslatedString, }, }, { @@ -182,7 +183,7 @@ export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({ name: "dateOfBirth", label: "Date of birth" as TranslatedString, pattern: "dd/MM/yyyy", - help: "format 'dd/MM/yyyy'" as TranslatedString, + // help: "format 'dd/MM/yyyy'" as TranslatedString, }, }, { @@ -198,7 +199,8 @@ export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({ name: "dateOfDeath", label: "Date of death" as TranslatedString, pattern: "dd/MM/yyyy", - help: "if deceased. format 'dd/MM/yyyy'" as TranslatedString, + help: "if deceased." as TranslatedString, + // help: "if deceased. format 'dd/MM/yyyy'" as TranslatedString, }, }, ], @@ -241,7 +243,7 @@ export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({ name: "dateOfBirth", label: "Date of birth" as TranslatedString, pattern: "dd/MM/yyyy", - help: "format 'dd/MM/yyyy'" as TranslatedString, + // help: "format 'dd/MM/yyyy'" as TranslatedString, }, }, { @@ -424,15 +426,6 @@ export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({ }, }, { - type: "date", - props: { - name: "when", - label: "Date" as TranslatedString, - pattern: "dd/MM/yyyy", - help: "format 'dd/MM/yyyy'" as TranslatedString, - }, - }, - { type: "text", props: { name: "signature", @@ -474,9 +467,6 @@ export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({ }; }), }, - when: { - disabled: true, - }, }; }, }); @@ -507,7 +497,7 @@ namespace Form902_13 { type Founder = WithRevoke<WithDeath<Person>>; type Beneficiary = WithClaim<Person>; - export interface Form extends Simplest.WithResolution { + export interface Form extends BaseForm { contractingPartner: string; knownAs: string; boardMember: string; diff --git a/packages/aml-backoffice-ui/src/forms/902_15e.ts b/packages/aml-backoffice-ui/src/forms/902_15e.ts index 502cee8e5..b9796add8 100644 --- a/packages/aml-backoffice-ui/src/forms/902_15e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_15e.ts @@ -1,10 +1,10 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { State } from "../pages/AntiMoneyLaunderingForm.js"; +import { BaseForm } from "../pages/AntiMoneyLaunderingForm.js"; import { FlexibleForm } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; -export const v1 = (current: State): FlexibleForm<Form902_15.Form> => ({ +export const v1 = (current: BaseForm): FlexibleForm<Form902_15.Form> => ({ versionId: "2023-05-15", design: [ { @@ -74,7 +74,7 @@ export const v1 = (current: State): FlexibleForm<Form902_15.Form> => ({ name: "holder.dateOfBirth", label: "Date of birth" as TranslatedString, pattern: "dd/MM/yyyy", - help: "format 'dd/MM/yyyy'" as TranslatedString, + // help: "format 'dd/MM/yyyy'" as TranslatedString, }, }, { @@ -115,7 +115,7 @@ export const v1 = (current: State): FlexibleForm<Form902_15.Form> => ({ name: "premiumPayer.dateOfBirth", label: "Date of birth" as TranslatedString, pattern: "dd/MM/yyyy", - help: "format 'dd/MM/yyyy'" as TranslatedString, + // help: "format 'dd/MM/yyyy'" as TranslatedString, }, }, { @@ -136,15 +136,6 @@ export const v1 = (current: State): FlexibleForm<Form902_15.Form> => ({ }, }, { - type: "date", - props: { - name: "when", - pattern: "dd/MM/yyyy", - label: "Date" as TranslatedString, - help: "format 'dd/MM/yyyy'" as TranslatedString, - }, - }, - { type: "text", props: { name: "signature", @@ -166,9 +157,6 @@ export const v1 = (current: State): FlexibleForm<Form902_15.Form> => ({ v: Partial<Form902_15.Form>, ): FormState<Form902_15.Form> { return { - when: { - disabled: true, - }, }; }, }); @@ -181,7 +169,7 @@ namespace Form902_15 { nationality: string; } - export interface Form extends Simplest.WithResolution { + export interface Form extends BaseForm { contractingPartner: string; contractualRelationship: string; insurancePolicy: string; diff --git a/packages/aml-backoffice-ui/src/forms/902_1e.ts b/packages/aml-backoffice-ui/src/forms/902_1e.ts index c212efb1a..2cd16b840 100644 --- a/packages/aml-backoffice-ui/src/forms/902_1e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_1e.ts @@ -1,36 +1,13 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { State } from "../pages/AntiMoneyLaunderingForm.js"; +import { BaseForm } from "../pages/AntiMoneyLaunderingForm.js"; import { FlexibleForm, languageList } from "./index.js"; -import { Simplest, resolutionSection } from "./simplest.js"; +import { resolutionSection } from "./simplest.js"; -export const v1 = (current: State): FlexibleForm<Form902_1.Form> => ({ +export const v1 = (current: BaseForm): FlexibleForm<Form902_1.Form> => ({ versionId: "2023-05-15", design: [ { - title: "This form was completed by" as TranslatedString, - description: - "The customer has to be identified on entering into a permanent business relationship or on concluding a cash transaction, which meets the according threshold." as TranslatedString, - fields: [ - { - type: "text", - props: { - name: "fullName", - label: "Full name" as TranslatedString, - }, - }, - { - type: "date", - props: { - name: "when", - pattern: "dd/MM/yyyy", - label: "Date" as TranslatedString, - help: "format 'dd/MM/yyyy'" as TranslatedString, - }, - }, - ], - }, - { title: "Information on customer" as TranslatedString, description: "The customer is the person with whom the member concludes the contract with regard to the financial service provided (civil law). Does the member act as director of a domiciliary company, this domiciliary company is the customer." as TranslatedString, @@ -89,6 +66,7 @@ export const v1 = (current: State): FlexibleForm<Form902_1.Form> => ({ name: "naturalCustomer.dateOfBirth", label: "Date of birth" as TranslatedString, required: true, + // help: "format 'dd/MM/yyyy'" as TranslatedString, }, }, { @@ -244,7 +222,7 @@ export const v1 = (current: State): FlexibleForm<Form902_1.Form> => ({ name: "dateOfBirth", label: "Date of birth" as TranslatedString, required: true, - help: "format 'dd/MM/yyyy'" as TranslatedString, + // help: "format 'dd/MM/yyyy'" as TranslatedString, }, }, { @@ -321,7 +299,7 @@ export const v1 = (current: State): FlexibleForm<Form902_1.Form> => ({ name: "acceptance.when", pattern: "dd/MM/yyyy", label: "Date (conclusion of contract)" as TranslatedString, - help: "format 'dd/MM/yyyy'" as TranslatedString, + // help: "format 'dd/MM/yyyy'" as TranslatedString, }, }, { @@ -520,9 +498,6 @@ export const v1 = (current: State): FlexibleForm<Form902_1.Form> => ({ fullName: { disabled: true, }, - when: { - disabled: true, - }, businessEstablisher: { elements: (v.businessEstablisher ?? []).map((be) => { return { @@ -659,11 +634,11 @@ namespace Form902_1 { interface BeneficialOwner { establishment: - | "natural-person" - | "foundation" - | "trust" - | "insurance-wrapper" - | "other"; + | "natural-person" + | "foundation" + | "trust" + | "insurance-wrapper" + | "other"; } interface CashTransactions { @@ -672,7 +647,7 @@ namespace Form902_1 { purpose: string; } - export interface Form extends Simplest.WithResolution { + export interface Form extends BaseForm { fullName: string; customerType: "natural" | "legal"; naturalCustomer: NaturalCustomer; diff --git a/packages/aml-backoffice-ui/src/forms/902_4e.ts b/packages/aml-backoffice-ui/src/forms/902_4e.ts index 7c47a8746..041f08c98 100644 --- a/packages/aml-backoffice-ui/src/forms/902_4e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_4e.ts @@ -1,12 +1,12 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { h as create } from "preact"; import { FormState } from "../handlers/FormProvider.js"; -import { State } from "../pages/AntiMoneyLaunderingForm.js"; +import { BaseForm } from "../pages/AntiMoneyLaunderingForm.js"; import { FlexibleForm } from "./index.js"; import { Simplest, resolutionSection } from "./simplest.js"; import { ArrowRightIcon, ChevronRightIcon } from "../pages/Cases.js"; -export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ +export const v1 = (current: BaseForm): FlexibleForm<Form902_4.Form> => ({ versionId: "2023-05-15", design: [ { @@ -33,27 +33,6 @@ export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ ], }, { - title: "This form was completed by" as TranslatedString, - fields: [ - { - type: "text", - props: { - label: "Full name" as TranslatedString, - name: "fullName", - }, - }, - { - type: "date", - props: { - name: "when", - pattern: "dd/MM/yyyy", - label: "Date" as TranslatedString, - help: "format 'dd/MM/yyyy'" as TranslatedString, - }, - }, - ], - }, - { title: "Evaluation of politically exposed persons (PEP-Check)" as TranslatedString, fields: [ @@ -69,8 +48,8 @@ export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ type: "choiceStacked", props: { label: "Foreign PEP" as TranslatedString, - tooltip: - "Definition see Art. 7 lit. g numeral 1 SRO Regulations" as TranslatedString, + // tooltip: + // "Definition see Art. 7 lit. g numeral 1 SRO Regulations" as TranslatedString, help: "Is the customer, the beneficial owner or the controlling person or authorized representative a foreign PEP or closely related to such a person?" as TranslatedString, name: "pep.foreign", choices: [ @@ -92,8 +71,8 @@ export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ props: { label: "Domestic PEP and PEP of International Organizations" as TranslatedString, - tooltip: - "Definition see Art. 7 lit. g numeral 2 and 3 SRO Regulations " as TranslatedString, + // tooltip: + // "Definition see Art. 7 lit. g numeral 2 and 3 SRO Regulations " as TranslatedString, help: "Is the customer, the beneficial owner or the controlling person or authorized representative a domestic PEP or PEP in International Organizations or closely related to such a person?" as TranslatedString, name: "pep.domestic", choices: [ @@ -123,7 +102,7 @@ export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ "The decision of the Senior executive body on the acceptance of a business relationship with a PEP was obtained on" as TranslatedString, name: "pep.when", pattern: "dd/MM/yyyy", - placeholder: "dd/MM/yyyy" as TranslatedString, + // placeholder: "dd/MM/yyyy" as TranslatedString, }, }, ], @@ -167,7 +146,7 @@ export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ "The decision of the Senior executive body on the acceptance of a business relationship with a PEP was obtained on" as TranslatedString, name: "highRisk.when", pattern: "dd/MM/yyyy", - placeholder: "dd/MM/yyyy" as TranslatedString, + // placeholder: "dd/MM/yyyy" as TranslatedString, }, }, ], @@ -613,7 +592,7 @@ export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ "The decision of the Senior executive body on the acceptance of a business relationship with a PEP was obtained on" as TranslatedString, name: "evaluation.when", pattern: "dd/MM/yyyy", - placeholder: "dd/MM/yyyy" as TranslatedString, + // placeholder: "dd/MM/yyyy" as TranslatedString, }, }, ], @@ -742,15 +721,12 @@ export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ v: Partial<Form902_4.Form>, ): FormState<Form902_4.Form> { return { - when: { - disabled: true, - }, }; }, }); namespace Form902_4 { - export interface Form extends Simplest.WithResolution { + export interface Form extends BaseForm { customer: string; fullName: string; pep: { @@ -778,24 +754,24 @@ namespace Form902_4 { industry: { nature: "customer" | "owner"; risk: - | "low" - | "medium-cash" - | "medium-unknown" - | "high-restricted" - | "high-unknown"; + | "low" + | "medium-cash" + | "medium-unknown" + | "high-restricted" + | "high-unknown"; }; contact: { risk: "low" | "medium" | "high"; }; product: { risk: - | "low" - | "medium" - | "high-offshore" - | "high-structure" - | "high-accounts" - | "high-service" - | "high-freq-tx"; + | "low" + | "medium" + | "high-offshore" + | "high-structure" + | "high-accounts" + | "high-service" + | "high-freq-tx"; }; custom: { definition: string; @@ -805,7 +781,6 @@ namespace Form902_4 { justification: string; risk: "with" | "without"; }; - when: AbsoluteTime; }; criteria: { additional: string; diff --git a/packages/aml-backoffice-ui/src/forms/902_5e.ts b/packages/aml-backoffice-ui/src/forms/902_5e.ts index 501a3b23c..c3948e1c7 100644 --- a/packages/aml-backoffice-ui/src/forms/902_5e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_5e.ts @@ -1,10 +1,10 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { State } from "../pages/AntiMoneyLaunderingForm.js"; +import { BaseForm } from "../pages/AntiMoneyLaunderingForm.js"; import { FlexibleForm, currencyList } from "./index.js"; -import { Simplest, resolutionSection } from "./simplest.js"; +import { resolutionSection } from "./simplest.js"; -export const v1 = (current: State): FlexibleForm<Form902_5.Form> => ({ +export const v1 = (current: BaseForm): FlexibleForm<Form902_5.Form> => ({ versionId: "2023-05-15", design: [ { @@ -20,22 +20,6 @@ export const v1 = (current: State): FlexibleForm<Form902_5.Form> => ({ help: "Pursuant Identification Form (VQF doc. No. 902.1) numeral 1" as TranslatedString, }, }, - { - type: "text", - props: { - name: "fullName", - label: "Full name" as TranslatedString, - }, - }, - { - type: "date", - props: { - name: "when", - pattern: "dd/MM/yyyy", - label: "Date" as TranslatedString, - help: "format 'dd/MM/yyyy'" as TranslatedString, - }, - }, ], }, { @@ -232,9 +216,6 @@ export const v1 = (current: State): FlexibleForm<Form902_5.Form> => ({ v: Partial<Form902_5.Form>, ): FormState<Form902_5.Form> { return { - when: { - disabled: true, - }, originOfAssets: { categoryOther: { hidden: v.originOfAssets?.category !== "other", @@ -245,7 +226,7 @@ export const v1 = (current: State): FlexibleForm<Form902_5.Form> => ({ }); namespace Form902_5 { - export interface Form extends Simplest.WithResolution { + export interface Form extends BaseForm { customer: string; fullName: string; businessActivity: string; diff --git a/packages/aml-backoffice-ui/src/forms/902_9e.ts b/packages/aml-backoffice-ui/src/forms/902_9e.ts index 04f0a1572..a5753d5d0 100644 --- a/packages/aml-backoffice-ui/src/forms/902_9e.ts +++ b/packages/aml-backoffice-ui/src/forms/902_9e.ts @@ -1,10 +1,10 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; -import { State } from "../pages/AntiMoneyLaunderingForm.js"; +import { BaseForm } from "../pages/AntiMoneyLaunderingForm.js"; import { FlexibleForm } from "./index.js"; -import { Simplest, resolutionSection } from "./simplest.js"; +import { resolutionSection } from "./simplest.js"; -export const v1 = (current: State): FlexibleForm<Form902_9.Form> => ({ +export const v1 = (current: BaseForm): FlexibleForm<Form902_9.Form> => ({ versionId: "2023-05-15", design: [ { @@ -19,15 +19,6 @@ export const v1 = (current: State): FlexibleForm<Form902_9.Form> => ({ }, }, { - type: "date", - props: { - name: "when", - pattern: "dd/MM/yyyy", - label: "Date" as TranslatedString, - help: "format 'dd/MM/yyyy'" as TranslatedString, - }, - }, - { type: "caption", props: { label: @@ -61,7 +52,7 @@ export const v1 = (current: State): FlexibleForm<Form902_9.Form> => ({ name: "dateOfBirth", label: "Date of birth" as TranslatedString, pattern: "dd/MM/yyyy", - help: "format 'dd/MM/yyyy'" as TranslatedString, + // help: "format 'dd/MM/yyyy'" as TranslatedString, }, }, { @@ -110,9 +101,6 @@ export const v1 = (current: State): FlexibleForm<Form902_9.Form> => ({ v: Partial<Form902_9.Form>, ): FormState<Form902_9.Form> { return { - when: { - disabled: true, - }, }; }, }); @@ -125,7 +113,7 @@ namespace Form902_9 { nationality: string; address: string; } - export interface Form extends Simplest.WithResolution { + export interface Form extends BaseForm { contractingPartner: string; persons: Person; signature: string; diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts index 6497f3949..9b462e1c5 100644 --- a/packages/aml-backoffice-ui/src/forms/simplest.ts +++ b/packages/aml-backoffice-ui/src/forms/simplest.ts @@ -6,13 +6,13 @@ import { } from "@gnu-taler/taler-util"; import { FormState } from "../handlers/FormProvider.js"; import { DoubleColumnFormSection } from "../handlers/forms.js"; -import { State } from "../pages/AntiMoneyLaunderingForm.js"; +import { BaseForm } from "../pages/AntiMoneyLaunderingForm.js"; import { AmlExchangeBackend } from "../types.js"; import { FlexibleForm } from "./index.js"; import { amlStateConverter } from "../pages/ShowConsolidated.js"; -export const v1 = (current: State): FlexibleForm<Simplest.Form> => ({ +export const v1 = (current: BaseForm): FlexibleForm<Simplest.Form> => ({ versionId: "2023-05-25", design: [ { @@ -33,9 +33,6 @@ export const v1 = (current: State): FlexibleForm<Simplest.Form> => ({ v: Partial<Simplest.Form>, ): FormState<Simplest.Form> { return { - when: { - disabled: true, - }, threshold: { disabled: v.state === AmlExchangeBackend.AmlState.frozen, }, @@ -44,17 +41,12 @@ export const v1 = (current: State): FlexibleForm<Simplest.Form> => ({ }); export namespace Simplest { - export interface WithResolution { - when: AbsoluteTime; - threshold: AmountJson; - state: AmlExchangeBackend.AmlState; - } - export interface Form extends WithResolution { + export interface Form extends BaseForm { comment: string; } } -export function resolutionSection(current: State): DoubleColumnFormSection { +export function resolutionSection(current: BaseForm): DoubleColumnFormSection { return { title: "Resolution" as TranslatedString, description: `Current state is ${amlStateConverter.toStringUI( @@ -64,13 +56,6 @@ export function resolutionSection(current: State): DoubleColumnFormSection { )}` as TranslatedString, fields: [ { - type: "date", - props: { - name: "when", - label: "Decision Time" as TranslatedString, - }, - }, - { type: "choiceHorizontal", props: { name: "state", diff --git a/packages/aml-backoffice-ui/src/handlers/Calendar.tsx b/packages/aml-backoffice-ui/src/handlers/Calendar.tsx new file mode 100644 index 000000000..9da6e1757 --- /dev/null +++ b/packages/aml-backoffice-ui/src/handlers/Calendar.tsx @@ -0,0 +1,116 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util" +import { useTranslationContext } from "@gnu-taler/web-util/browser" +import { add as dateAdd, sub as dateSub, eachDayOfInterval, endOfMonth, endOfWeek, format, getMonth, getYear, isSameDay, isSameMonth, startOfDay, startOfMonth, startOfWeek } from "date-fns" +import { VNode, h } from "preact" +import { useState } from "preact/hooks" + +export function Calendar({ value, onChange }: { value: AbsoluteTime | undefined, onChange: (v: AbsoluteTime) => void }): VNode { + const today = startOfDay(new Date()) + const selected = !value ? today : new Date(AbsoluteTime.toStampMs(value)) + const [showingDate, setShowingDate] = useState(selected) + const month = getMonth(showingDate) + const year = getYear(showingDate) + + const start = startOfWeek(startOfMonth(showingDate)); + const end = endOfWeek(endOfMonth(showingDate)); + const daysInMonth = eachDayOfInterval({ start, end }); + const { i18n } = useTranslationContext() + const monthNames = [ + i18n.str`January`, + i18n.str`February`, + i18n.str`March`, + i18n.str`April`, + i18n.str`May`, + i18n.str`June`, + i18n.str`July`, + i18n.str`August`, + i18n.str`September`, + i18n.str`October`, + i18n.str`November`, + i18n.str`December`, + ] + return <div class="text-center p-2"> + <div class="flex items-center text-gray-900"> + <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateSub(showingDate, { years: 1 })) + }}> + <span class="sr-only"> + {i18n.str`Previous year`} + </span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" /> + </svg> + </button> + <div class="flex-auto text-sm font-semibold">{year}</div> + <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateAdd(showingDate, { years: 1 })) + }}> + <span class="sr-only"> + {i18n.str`Next year`} + </span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" /> + </svg> + </button> + </div> + <div class="mt-4 flex items-center text-gray-900"> + <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateSub(showingDate, { months: 1 })) + }}> + <span class="sr-only"> + {i18n.str`Previous month`} + </span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" /> + </svg> + </button> + <div class="flex-auto text-sm font-semibold">{monthNames[month]}</div> + <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 rounded-sm " + onClick={() => { + setShowingDate(dateAdd(showingDate, { months: 1 })) + }}> + <span class="sr-only"> + {i18n.str`Next month`} + </span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" /> + </svg> + </button> + </div> + <div class="mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500"> + <div>M</div> + <div>T</div> + <div>W</div> + <div>T</div> + <div>F</div> + <div>S</div> + <div>S</div> + </div> + <div class="isolate mt-2 grid grid-cols-7 gap-px rounded-lg bg-gray-200 text-sm shadow ring-1 ring-gray-200"> + {daysInMonth.map(current => ( + <button type="button" + data-month={isSameMonth(current, showingDate)} + data-today={isSameDay(current, today)} + data-selected={isSameDay(current, selected)} + onClick={() => { + onChange(AbsoluteTime.fromStampMs(current.getTime())) + }} + class="text-gray-400 hover:bg-gray-700 focus:z-10 py-1.5 + data-[month=false]:bg-gray-100 data-[month=true]:bg-white + data-[today=true]:font-semibold + data-[month=true]:text-gray-900 + data-[today=true]:bg-red-300 data-[today=true]:hover:bg-red-200 + data-[month=true]:hover:bg-gray-200 + data-[selected=true]:!bg-blue-400 data-[selected=true]:hover:!bg-blue-300 "> + <time dateTime={format(current, "yyyy-MM-dd")} + class="mx-auto flex h-7 w-7 items-center justify-center rounded-full"> + {format(current, "dd")} + </time> + </button> + ))} + </div> + </div> +} diff --git a/packages/aml-backoffice-ui/src/handlers/Dialog.tsx b/packages/aml-backoffice-ui/src/handlers/Dialog.tsx new file mode 100644 index 000000000..f9899e94e --- /dev/null +++ b/packages/aml-backoffice-ui/src/handlers/Dialog.tsx @@ -0,0 +1,15 @@ +import { ComponentChildren, VNode, h } from "preact"; + +export function Dialog({ children, onClose }: { onClose?: () => void; children: ComponentChildren }): VNode { + return <div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true" onClick={onClose}> + <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div> + + <div class="fixed inset-0 z-10 w-screen overflow-y-auto"> + <div class="flex min-h-full items-center justify-center p-4 text-center sm:items-center sm:p-0"> + <div class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> + {children} + </div> + </div> + </div> + </div> +} diff --git a/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx b/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx index 3da2a4f07..310954bd0 100644 --- a/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx +++ b/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx @@ -7,14 +7,13 @@ import { ComponentChildren, VNode, createContext, h } from "preact"; import { MutableRef, StateUpdater, - useEffect, - useRef, - useState, + useState } from "preact/hooks"; export interface FormType<T> { value: MutableRef<Partial<T>>; initialValue?: Partial<T>; + readOnly?: boolean; onUpdate?: StateUpdater<T>; computeFormState?: (v: T) => FormState<T>; } @@ -24,14 +23,14 @@ export const FormContext = createContext<FormType<any>>({}); export type FormState<T> = { [field in keyof T]?: T[field] extends AbsoluteTime - ? Partial<InputFieldState> - : T[field] extends AmountJson - ? Partial<InputFieldState> - : T[field] extends Array<infer P> - ? Partial<InputArrayFieldState<P>> - : T[field] extends (object | undefined) - ? FormState<T[field]> - : Partial<InputFieldState>; + ? Partial<InputFieldState> + : T[field] extends AmountJson + ? Partial<InputFieldState> + : T[field] extends Array<infer P> + ? Partial<InputArrayFieldState<P>> + : T[field] extends (object | undefined) + ? FormState<T[field]> + : Partial<InputFieldState>; }; export interface InputFieldState { @@ -55,11 +54,13 @@ export function FormProvider<T>({ onUpdate: notify, onSubmit, computeFormState, + readOnly, }: { initialValue?: Partial<T>; onUpdate?: (v: Partial<T>) => void; onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void; computeFormState?: (v: Partial<T>) => FormState<T>; + readOnly?: boolean; children: ComponentChildren; }): VNode { // const value = useRef(initialValue ?? {}); @@ -79,7 +80,7 @@ export function FormProvider<T>({ }; return ( <FormContext.Provider - value={{ initialValue, value, onUpdate, computeFormState }} + value={{ initialValue, value, onUpdate, computeFormState, readOnly }} > <form onSubmit={(e) => { diff --git a/packages/aml-backoffice-ui/src/handlers/InputArray.tsx b/packages/aml-backoffice-ui/src/handlers/InputArray.tsx index 00379bed6..d229b35de 100644 --- a/packages/aml-backoffice-ui/src/handlers/InputArray.tsx +++ b/packages/aml-backoffice-ui/src/handlers/InputArray.tsx @@ -107,22 +107,24 @@ export function InputArray<T extends object, K extends keyof T>( /> ); })} - <div class="pt-2"> - <Option - label={"Add..." as TranslatedString} - isSelected={selectedIndex === list.length} - isLast - isFirst - disabled={ - selectedIndex !== undefined && selectedIndex !== list.length - } - onClick={() => { - setSelected( - selectedIndex === list.length ? undefined : list.length, - ); - }} - /> - </div> + {!state.disabled && + <div class="pt-2"> + <Option + label={"Add..." as TranslatedString} + isSelected={selectedIndex === list.length} + isLast + isFirst + disabled={ + selectedIndex !== undefined && selectedIndex !== list.length + } + onClick={() => { + setSelected( + selectedIndex === list.length ? undefined : list.length, + ); + }} + /> + </div> + } </div> {selectedIndex !== undefined && ( /** @@ -131,6 +133,7 @@ export function InputArray<T extends object, K extends keyof T>( */ <FormProvider initialValue={selected} + readOnly={state.disabled} computeFormState={(v) => { // current state is ignored // the state is defined by the parent form diff --git a/packages/aml-backoffice-ui/src/handlers/InputChoiceHorizontal.tsx b/packages/aml-backoffice-ui/src/handlers/InputChoiceHorizontal.tsx index fdee35447..a5f263615 100644 --- a/packages/aml-backoffice-ui/src/handlers/InputChoiceHorizontal.tsx +++ b/packages/aml-backoffice-ui/src/handlers/InputChoiceHorizontal.tsx @@ -61,6 +61,7 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( return ( <button type="button" + disabled={state.disabled} class={clazz} onClick={(e) => { onChange( diff --git a/packages/aml-backoffice-ui/src/handlers/InputChoiceStacked.tsx b/packages/aml-backoffice-ui/src/handlers/InputChoiceStacked.tsx index c37984368..29c596994 100644 --- a/packages/aml-backoffice-ui/src/handlers/InputChoiceStacked.tsx +++ b/packages/aml-backoffice-ui/src/handlers/InputChoiceStacked.tsx @@ -60,6 +60,7 @@ export function InputChoiceStacked<T extends object, K extends keyof T>( type="radio" name="server-size" // defaultValue={choice.value} + disabled={state.disabled} value={ (!converter ? (choice.value as string) diff --git a/packages/aml-backoffice-ui/src/handlers/InputDate.tsx b/packages/aml-backoffice-ui/src/handlers/InputDate.tsx index 0f286e001..7fcc16b33 100644 --- a/packages/aml-backoffice-ui/src/handlers/InputDate.tsx +++ b/packages/aml-backoffice-ui/src/handlers/InputDate.tsx @@ -1,40 +1,65 @@ import { AbsoluteTime } from "@gnu-taler/taler-util"; import { InputLine, UIFormProps } from "./InputLine.js"; -import { VNode, h } from "preact"; +import { Fragment, VNode, h } from "preact"; import { format, parse } from "date-fns"; +import { Dialog } from "./Dialog.js"; +import { Calendar } from "./Calendar.js"; +import { useState } from "preact/hooks"; +import { useField } from "./useField.js"; export function InputDate<T extends object, K extends keyof T>( props: { pattern?: string } & UIFormProps<T, K>, ): VNode { const pattern = props.pattern ?? "dd/MM/yyyy"; + const [open, setOpen] = useState(false) + const { value, onChange } = useField<T, K>(props.name); return ( - <InputLine<T, K> - type="text" - after={{ - type: "icon", - // icon: <CalendarIcon class="h-6 w-6" />, - icon: <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="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" /> - </svg> + <Fragment> - }} - converter={{ - //@ts-ignore - fromStringUI: (v): AbsoluteTime => { - if (!v) return AbsoluteTime.never(); - const t_ms = parse(v, pattern, Date.now()).getTime(); - return AbsoluteTime.fromMilliseconds(t_ms); - }, - //@ts-ignore - toStringUI: (v: AbsoluteTime) => { - return !v || !v.t_ms - ? "" - : v.t_ms === "never" - ? "never" - : format(v.t_ms, pattern); - }, - }} - {...props} - /> + <InputLine<T, K> + type="text" + after={{ + type: "button", + onClick: () => { + setOpen(true) + }, + // icon: <CalendarIcon class="h-6 w-6" />, + children: ( + <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="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" /> + </svg>) + }} + converter={{ + //@ts-ignore + fromStringUI: (v): AbsoluteTime | undefined => { + if (!v) return undefined; + try { + const t_ms = parse(v, pattern, Date.now()).getTime(); + return AbsoluteTime.fromMilliseconds(t_ms); + } catch (e) { + return undefined; + } + }, + //@ts-ignore + toStringUI: (v: AbsoluteTime | undefined) => { + return !v || !v.t_ms + ? undefined + : v.t_ms === "never" + ? "never" + : format(v.t_ms, pattern); + }, + }} + {...props} + /> + {open && + <Dialog onClose={() => setOpen(false)}> + <Calendar value={value as AbsoluteTime ?? AbsoluteTime.now()} + onChange={(v) => { + onChange(v as any) + setOpen(false) + }} /> + </Dialog> + } + </Fragment> ); } diff --git a/packages/aml-backoffice-ui/src/handlers/InputFile.tsx b/packages/aml-backoffice-ui/src/handlers/InputFile.tsx index 0d89a98a3..d9af03f86 100644 --- a/packages/aml-backoffice-ui/src/handlers/InputFile.tsx +++ b/packages/aml-backoffice-ui/src/handlers/InputFile.tsx @@ -42,40 +42,42 @@ export function InputFile<T extends object, K extends keyof T>( clip-rule="evenodd" /> </svg> - <div class="my-2 flex text-sm leading-6 text-gray-600"> - <label - for="file-upload" - class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" - > - <span>Upload a file</span> - <input - id="file-upload" - name="file-upload" - type="file" - class="sr-only" - accept={accept} - onChange={(e) => { - const f: FileList | null = e.currentTarget.files; - if (!f || f.length != 1) { - return onChange(undefined!); - } - if (f[0].size > maxBites) { - return onChange(undefined!); - } - return f[0].arrayBuffer().then((b) => { - const b64 = window.btoa( - new Uint8Array(b).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - ); - return onChange(`data:${f[0].type};base64,${b64}` as any); - }); - }} - /> - </label> - {/* <p class="pl-1">or drag and drop</p> */} - </div> + {!state.disabled && + <div class="my-2 flex text-sm leading-6 text-gray-600"> + <label + for="file-upload" + class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" + > + <span>Upload a file</span> + <input + id="file-upload" + name="file-upload" + type="file" + class="sr-only" + accept={accept} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) { + return onChange(undefined!); + } + if (f[0].size > maxBites) { + return onChange(undefined!); + } + return f[0].arrayBuffer().then((b) => { + const b64 = window.btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + return onChange(`data:${f[0].type};base64,${b64}` as any); + }); + }} + /> + </label> + {/* <p class="pl-1">or drag and drop</p> */} + </div> + } </div> </div> ) : ( @@ -85,14 +87,16 @@ export function InputFile<T extends object, K extends keyof T>( class=" h-24 w-full object-cover relative" /> - <div - class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer " - onClick={() => { - onChange(undefined!); - }} - > - Clear - </div> + {!state.disabled && + <div + class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer " + onClick={() => { + onChange(undefined!); + }} + > + Clear + </div> + } </div> )} {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>} diff --git a/packages/aml-backoffice-ui/src/handlers/InputLine.tsx b/packages/aml-backoffice-ui/src/handlers/InputLine.tsx index 9448ef5e4..f6c709d94 100644 --- a/packages/aml-backoffice-ui/src/handlers/InputLine.tsx +++ b/packages/aml-backoffice-ui/src/handlers/InputLine.tsx @@ -1,6 +1,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { useField } from "./useField.js"; +import { useEffect, useState } from "preact/hooks"; export interface IconAddon { type: "icon"; @@ -80,7 +81,7 @@ export function LabelWithTooltipMaybeRequired({ {Label} <span class="relative flex items-center group pl-2"> {TooltipIcon} - <div class="absolute bottom-0 flex flex-col items-center hidden mb-6 group-hover:flex"> + <div class="absolute bottom-0 flex flex-col items-center mb-6 group-hover:flex"> <span class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg"> {tooltip} </span> @@ -110,8 +111,9 @@ function InputWrapper<T extends object, K extends keyof T>({ after, help, error, + disabled, required, -}: { error?: string; children: ComponentChildren } & UIFormProps<T, K>): VNode { +}: { error?: string; disabled: boolean, children: ComponentChildren } & UIFormProps<T, K>): VNode { return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -132,6 +134,7 @@ function InputWrapper<T extends object, K extends keyof T>({ ) : before.type === "button" ? ( <button type="button" + disabled={disabled} onClick={before.onClick} class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" > @@ -153,6 +156,7 @@ function InputWrapper<T extends object, K extends keyof T>({ ) : after.type === "button" ? ( <button type="button" + disabled={disabled} onClick={after.onClick} class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" > @@ -189,6 +193,21 @@ export function InputLine<T extends object, K extends keyof T>( const { name, placeholder, before, after, converter, type } = props; const { value, onChange, state, isDirty } = useField<T, K>(name); + const [text, setText] = useState("") + const fromString: (s: string) => any = + converter?.fromStringUI ?? defaultFromString; + const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; + + useEffect(() => { + const newValue = toString(value) + if (newValue) { + + setText(newValue) + } else { + console.log("invalid") + } + }, [value]) + if (state.hidden) return <div />; let clazz = @@ -233,14 +252,12 @@ export function InputLine<T extends object, K extends keyof T>( clazz += " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600"; } - const fromString: (s: string) => any = - converter?.fromStringUI ?? defaultFromString; - const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; if (type === "text-area") { return ( <InputWrapper<T, K> {...props} + disabled={state.disabled} error={showError ? state.error : undefined} > <textarea @@ -262,15 +279,18 @@ export function InputLine<T extends object, K extends keyof T>( } return ( - <InputWrapper<T, K> {...props} error={showError ? state.error : undefined}> + <InputWrapper<T, K> {...props} disabled={state.disabled} error={showError ? state.error : undefined}> <input name={String(name)} type={type} onChange={(e) => { - onChange(fromString(e.currentTarget.value)); + setText(e.currentTarget.value) }} placeholder={placeholder ? placeholder : undefined} - value={toString(value) ?? ""} + value={text} + onBlur={() => { + onChange(fromString(text)); + }} // defaultValue={toString(value)} disabled={state.disabled} aria-invalid={showError} diff --git a/packages/aml-backoffice-ui/src/handlers/InputSelectMultiple.tsx b/packages/aml-backoffice-ui/src/handlers/InputSelectMultiple.tsx index 837744827..6e6186a88 100644 --- a/packages/aml-backoffice-ui/src/handlers/InputSelectMultiple.tsx +++ b/packages/aml-backoffice-ui/src/handlers/InputSelectMultiple.tsx @@ -13,7 +13,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( ): VNode { const { name, label, choices, placeholder, tooltip, required, unique, max } = props; - const { value, onChange } = useField<T, K>(name); + const { value, onChange, state } = useField<T, K>(name); const [filter, setFilter] = useState<string | undefined>(undefined); const regex = new RegExp(`.*${filter}.*`, "i"); @@ -26,8 +26,8 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( filter === undefined ? undefined : choices.filter((v) => { - return regex.test(v.label); - }); + return regex.test(v.label); + }); return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -41,6 +41,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( {choiceMap[v]} <button type="button" + disabled={state.disabled} onClick={() => { const newValue = [...list]; newValue.splice(idx, 1); @@ -62,7 +63,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( ); })} - <div class="relative mt-2"> + {!state.disabled && <div class="relative mt-2"> <input id="combobox" type="text" @@ -78,6 +79,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( /> <button type="button" + disabled={state.disabled} onClick={() => { setFilter(filter === undefined ? "" : undefined); }} @@ -122,7 +124,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( onChange(newValue as T[K]); }} - // tabindex="-1" + // tabindex="-1" > {/* <!-- Selected: "font-semibold" --> */} <span class="block truncate">{v.label}</span> @@ -145,7 +147,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( {/* <!-- More items... --> */} </ul> )} - </div> + </div>} </div> ); } diff --git a/packages/aml-backoffice-ui/src/handlers/useField.ts b/packages/aml-backoffice-ui/src/handlers/useField.ts index bf94d2f5d..7eec5c5f8 100644 --- a/packages/aml-backoffice-ui/src/handlers/useField.ts +++ b/packages/aml-backoffice-ui/src/handlers/useField.ts @@ -16,6 +16,7 @@ export function useField<T extends object, K extends keyof T>( value: formValue, computeFormState, onUpdate: notifyUpdate, + readOnly: readOnlyForm, } = useContext(FormContext); type P = typeof name; @@ -30,8 +31,8 @@ export function useField<T extends object, K extends keyof T>( //compute default state const state = { - disabled: fieldState.disabled ?? false, - readonly: fieldState.readonly ?? false, + disabled: readOnlyForm ? true : (fieldState.disabled ?? false), + readonly: readOnlyForm ? true : (fieldState.readonly ?? false), hidden: fieldState.hidden ?? false, error: fieldState.error, elements: "elements" in fieldState ? fieldState.elements ?? [] : [], diff --git a/packages/aml-backoffice-ui/src/hooks/useCases.ts b/packages/aml-backoffice-ui/src/hooks/useCases.ts index c4edd9207..81ca2755a 100644 --- a/packages/aml-backoffice-ui/src/hooks/useCases.ts +++ b/packages/aml-backoffice-ui/src/hooks/useCases.ts @@ -12,7 +12,6 @@ import { useOfficer } from "./useOfficer.js"; const useSWR = _useSWR as unknown as SWRHook; const PAGE_SIZE = 10; -const MAX_RESULT_SIZE = PAGE_SIZE * 2 - 1; /** * FIXME: mutate result when balance change (transaction ) * @param account @@ -24,11 +23,11 @@ export function useCases(state: AmlExchangeBackend.AmlState) { const session = officer.state === "ready" ? officer.account : undefined; const { api } = useExchangeApiContext(); - const [offset, setOffet] = useState<string>(); + const [offset, setOffset] = useState<string>(); async function fetcher([officer, state, offset]: [OfficerAccount, AmlExchangeBackend.AmlState, string | undefined]) { return await api.getDecisionsByState(officer, state, { - order: "asc", offset, limit: MAX_RESULT_SIZE + order: "asc", offset, limit: PAGE_SIZE + 1 }) } @@ -51,7 +50,7 @@ export function useCases(state: AmlExchangeBackend.AmlState) { // if the query returns less that we ask, then we have reach the end or beginning const isLastPage = - data && data.type === "ok" && data.body.records.length < PAGE_SIZE; + data && data.type === "ok" && data.body.records.length <= PAGE_SIZE; const isFirstPage = !offset; const pagination = { @@ -60,12 +59,10 @@ export function useCases(state: AmlExchangeBackend.AmlState) { loadMore: () => { if (isLastPage || data?.type !== "ok") return; const list = data.body.records - if (list.length < MAX_RESULT_SIZE) { - // setOffset(list[list.length-1].account_name); - } + setOffset(String(list[list.length - 1].rowid)); }, - loadMorePrev: () => { - null; + reset: () => { + setOffset(undefined) }, }; @@ -79,11 +76,13 @@ export function useCases(state: AmlExchangeBackend.AmlState) { } as OperationFail<never> } } + if (data) { if (data.type === "fail") { return { data } } - return { data, pagination } + const records = isLastPage ? data.body.records : removeLastElement(data.body.records) + return { data: { type: "ok" as const, body: { records } }, pagination } } if (error) { return error; @@ -137,3 +136,9 @@ const example1: TalerExchangeApi.AmlRecords = { }; +function removeLastElement<T>(list: Array<T>): Array<T> { + if (list.length === 0) { + return list; + } + return list.slice(0, -1) +}
\ No newline at end of file diff --git a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx index a14966cc0..0b055f682 100644 --- a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx +++ b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.stories.tsx @@ -30,65 +30,75 @@ export default { export const SimpleComment = tests.createExample(TestedComponent, { account: "the_account", - selectedForm: 0, + formId: "simple_comment", onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({justification, newState, newThreshold}, undefined, 2)) + alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) } }); + export const Identification = tests.createExample(TestedComponent, { account: "the_account", - selectedForm: 1, + formId: "902.1e", onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({justification, newState, newThreshold}, undefined, 2)) + alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) } }); + export const OperationalLegalEntity = tests.createExample(TestedComponent, { account: "the_account", - selectedForm: 2, + formId: "902.11e", + onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({justification, newState, newThreshold}, undefined, 2)) + alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) } }); export const Foundations = tests.createExample(TestedComponent, { account: "the_account", - selectedForm: 3, + formId: "902.12e", + onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({justification, newState, newThreshold}, undefined, 2)) + alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) } }); export const DelcarationOfTrusts = tests.createExample(TestedComponent, { account: "the_account", - selectedForm: 4, + formId: "902.13e", + onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({justification, newState, newThreshold}, undefined, 2)) + alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) } }); + export const InformationOnLifeInsurance = tests.createExample(TestedComponent, { account: "the_account", - selectedForm: 5, + formId: "902.15e", + onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({justification, newState, newThreshold}, undefined, 2)) + alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) } }); export const DeclarationOfBeneficialOwner = tests.createExample(TestedComponent, { account: "the_account", - selectedForm: 6, + formId: "902.9e", + onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({justification, newState, newThreshold}, undefined, 2)) + alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) } }); export const CustomerProfile = tests.createExample(TestedComponent, { account: "the_account", - selectedForm: 7, + formId: "902.5e", + onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({justification, newState, newThreshold}, undefined, 2)) + alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) } }); export const RiskProfile = tests.createExample(TestedComponent, { account: "the_account", - selectedForm: 8, + formId: "902.4e", + onSubmit: async (justification, newState, newThreshold) => { - alert(JSON.stringify({justification, newState, newThreshold}, undefined, 2)) + alert(JSON.stringify({ justification, newState, newThreshold }, undefined, 2)) } }); diff --git a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx index faf9671bb..d1fb3b895 100644 --- a/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx +++ b/packages/aml-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx @@ -1,5 +1,5 @@ -import { AbsoluteTime, AmountJson, Amounts } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { AbsoluteTime, AmountJson, Amounts, Codec, OperationResult, buildCodecForObject, codecForNumber, codecForString, codecOptional } from "@gnu-taler/taler-util"; +import { FlexibleForm, useTranslationContext } from "@gnu-taler/web-util/browser"; import { h } from "preact"; import { NiceForm } from "../NiceForm.js"; import { v1 as form_902_11e_v1 } from "../forms/902_11e.js"; @@ -10,29 +10,21 @@ import { v1 as form_902_1e_v1 } from "../forms/902_1e.js"; import { v1 as form_902_4e_v1 } from "../forms/902_4e.js"; import { v1 as form_902_5e_v1 } from "../forms/902_5e.js"; import { v1 as form_902_9e_v1 } from "../forms/902_9e.js"; -import { v1 as simplest } from "../forms/simplest.js"; +import { Simplest, v1 as simplest } from "../forms/simplest.js"; import { Pages } from "../pages.js"; import { AmlExchangeBackend } from "../types.js"; import { useExchangeApiContext } from "../context/config.js"; -export type Justification = { - // form index in the list of forms - index: number; - // form name - name: string; - // form values - value: any; -} - -export function AntiMoneyLaunderingForm({ account, selectedForm, onSubmit }: { account: string, selectedForm: number, onSubmit: (justification: Justification, state: AmlExchangeBackend.AmlState, threshold: AmountJson) => Promise<void>; }) { +export function AntiMoneyLaunderingForm({ account, formId, onSubmit }: { account: string, formId: string, onSubmit: (justification: Justification, state: AmlExchangeBackend.AmlState, threshold: AmountJson) => Promise<void>; }) { const { i18n } = useTranslationContext() - const showingFrom = allForms[selectedForm].impl; - const formName = allForms[selectedForm].name + const theForm = allForms.find((v) => v.id === formId) + if (!theForm) { + return <div>form with id {formId} not found</div> + } const { config } = useExchangeApiContext() const initial = { - fullName: "loggedIn_user_fullname", when: AbsoluteTime.now(), state: AmlExchangeBackend.AmlState.pending, threshold: Amounts.zeroOfCurrency(config.currency), @@ -40,16 +32,17 @@ export function AntiMoneyLaunderingForm({ account, selectedForm, onSubmit }: { a return ( <NiceForm initial={initial} - form={showingFrom(initial)} + form={theForm.impl(initial)} onUpdate={() => { }} onSubmit={(formValue) => { if (formValue.state === undefined || formValue.threshold === undefined) return; const st = formValue.state; const amount = formValue.threshold; - const justification = { - index: selectedForm, - name: formName, + const justification: Justification = { + id: theForm.id, + label: theForm.label, + version: theForm.version, value: formValue } @@ -75,7 +68,7 @@ export function AntiMoneyLaunderingForm({ account, selectedForm, onSubmit }: { a ); } -export interface State { +export interface BaseForm { state: AmlExchangeBackend.AmlState; threshold: AmountJson; } @@ -85,49 +78,146 @@ const DocumentDuplicateIcon = <svg xmlns="http://www.w3.org/2000/svg" fill="none </svg> -export const allForms = [ +export type FormMetadata = { + label: string, + id: string, + version: number, + icon: h.JSX.Element, + impl: (current: BaseForm) => FlexibleForm<BaseForm> +} + +export type Justification<T = any> = { + // form values + value: T; +} & Omit<Omit<FormMetadata, "icon">, "impl"> + +export function stringifyJustification(j: Justification): string { + return JSON.stringify(j) +} + + +type SimpleFormMetadata = { + version?: number, + id?: string, +} + +export const codecForSimpleFormMetadata = (): Codec<SimpleFormMetadata> => + buildCodecForObject<SimpleFormMetadata>() + .property("id", codecOptional(codecForString())) + .property("version", codecOptional(codecForNumber())) + .build("SimpleFormMetadata"); + +type ParseJustificationFail = + "not-json" | + "id-not-found" | + "form-not-found" | + "version-not-found"; + +export function parseJustification(s: string, listOfAllKnownForms: FormMetadata[]): OperationResult<{ justification: Justification, metadata: FormMetadata }, ParseJustificationFail> { + try { + const justification = JSON.parse(s) + const info = codecForSimpleFormMetadata().decode(justification) + if (!info.id) { + return { + type: "fail", + case: "id-not-found", + detail: {} as any + } + } + if (!info.version) { + return { + type: "fail", + case: "version-not-found", + detail: {} as any + } + } + const found = listOfAllKnownForms.find((f) => { + return f.id === info.id && f.version === info.version + }) + if (!found) { + return { + type: "fail", + case: "form-not-found", + detail: {} as any + } + } + return { + type: "ok", + body: { + justification, metadata: found + } + } + } catch (e) { + return { + type: "fail", + case: "not-json", + detail: {} as any + } + } + +} + +export const allForms: Array<FormMetadata> = [ { - name: "Simple comment", + label: "Simple comment", + id: "simple_comment", + version: 1, icon: DocumentDuplicateIcon, impl: simplest, }, { - name: "Identification form (902.1e)", + label: "Identification form", + id: "902.1e", + version: 1, icon: DocumentDuplicateIcon, impl: form_902_1e_v1, }, { - name: "Operational legal entity or partnership (902.11e)", + label: "Operational legal entity or partnership", + id: "902.11e", + version: 1, icon: DocumentDuplicateIcon, impl: form_902_11e_v1, }, { - name: "Foundations (902.12e)", + label: "Foundations", + id: "902.12e", + version: 1, icon: DocumentDuplicateIcon, impl: form_902_12e_v1, }, { - name: "Declaration for trusts (902.13e)", + label: "Declaration for trusts", + id: "902.13e", + version: 1, icon: DocumentDuplicateIcon, impl: form_902_13e_v1, }, { - name: "Information on life insurance policies (902.15e)", + label: "Information on life insurance policies", + id: "902.15e", + version: 1, icon: DocumentDuplicateIcon, impl: form_902_15e_v1, }, { - name: "Declaration of beneficial owner (902.9e)", + label: "Declaration of beneficial owner", + id: "902.9e", + version: 1, icon: DocumentDuplicateIcon, impl: form_902_9e_v1, }, { - name: "Customer profile (902.5e)", + label: "Customer profile", + id: "902.5e", + version: 1, icon: DocumentDuplicateIcon, impl: form_902_5e_v1, }, { - name: "Risk profile (902.4e)", + label: "Risk profile", + id: "902.4e", + version: 1, icon: DocumentDuplicateIcon, impl: form_902_4e_v1, }, diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx index 1f8d6ac5e..1cfa65926 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -2,6 +2,7 @@ import { AbsoluteTime, AmountJson, Amounts, + OperationResult, TalerError, TranslatedString, assertUnreachable @@ -14,12 +15,25 @@ import { useCaseDetails } from "../hooks/useCaseDetails.js"; import { Pages } from "../pages.js"; import { AmlExchangeBackend } from "../types.js"; import { ShowConsolidated } from "./ShowConsolidated.js"; +import { FormMetadata, Justification, allForms, parseJustification } from "./AntiMoneyLaunderingForm.js"; +import { NiceForm } from "../NiceForm.js"; -export type AmlEvent = AmlFormEvent | KycCollectionEvent | KycExpirationEvent; +export type AmlEvent = AmlFormEvent | AmlFormEventError | KycCollectionEvent | KycExpirationEvent; type AmlFormEvent = { type: "aml-form"; when: AbsoluteTime; title: TranslatedString; + justification: Justification; + metadata: FormMetadata; + state: AmlExchangeBackend.AmlState; + threshold: AmountJson; +}; +type AmlFormEventError = { + type: "aml-form-error"; + when: AbsoluteTime; + title: TranslatedString; + justification: undefined, + metadata: undefined, state: AmlExchangeBackend.AmlState; threshold: AmountJson; }; @@ -43,16 +57,32 @@ function selectSooner(a: WithTime, b: WithTime) { return AbsoluteTime.cmp(a.when, b.when); } +function titleForJustification(op: ReturnType<typeof parseJustification>): TranslatedString { + if (op.type === "ok") { + return op.body.justification.label as TranslatedString; + } + switch (op.case) { + case "not-json": return "error: the justification is not a form" as TranslatedString + case "id-not-found": return "error: justification form's id not found" as TranslatedString + case "version-not-found": return "error: justification form's version not found" as TranslatedString + case "form-not-found": return `error: justification form not found` as TranslatedString + } + assertUnreachable(op.case) +} + export function getEventsFromAmlHistory( aml: AmlExchangeBackend.AmlDecisionDetail[], kyc: AmlExchangeBackend.KycDetail[], ): AmlEvent[] { const ae: AmlEvent[] = aml.map((a) => { + const just = parseJustification(a.justification, allForms) return { - type: "aml-form", + type: just.type === "ok" ? "aml-form" : "aml-form-error", state: a.new_state, threshold: Amounts.parseOrThrow(a.new_threshold), - title: a.justification as TranslatedString, + title: titleForJustification(just), + metadata: just.type === "ok" ? just.body.metadata : undefined, + justification: just.type === "ok" ? just.body.justification : undefined, when: { t_ms: a.decision_time.t_s === "never" @@ -81,7 +111,8 @@ export function getEventsFromAmlHistory( } export function CaseDetails({ account }: { account: string }) { - const [selected, setSelected] = useState<AmlEvent | undefined>(undefined); + const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now()); + const [showForm, setShowForm] = useState<{ justification: Justification, metadata: FormMetadata }>() const { i18n } = useTranslationContext(); const details = useCaseDetails(account) @@ -103,6 +134,25 @@ export function CaseDetails({ account }: { account: string }) { const events = getEventsFromAmlHistory(aml_history, kyc_attributes); + if (showForm !== undefined) { + return <NiceForm + readOnly={true} + initial={showForm.justification.value} + form={showForm.metadata.impl(showForm.justification.value)} + > + <div class="mt-6 flex items-center justify-end gap-x-6"> + <button + class="text-sm font-semibold leading-6 text-gray-900" + onClick={() => { + setShowForm(undefined) + }} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + </div> + + </NiceForm> + } return ( <div> <a @@ -117,116 +167,138 @@ export function CaseDetails({ account }: { account: string }) { <header class="flex items-center justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8"> <h1 class="text-base font-semibold leading-7 text-black"> <i18n.Translate> - Case history + Case history for account <span title={account}>{account.substring(0, 16)}...</span> </i18n.Translate> </h1> </header> - <div class="flow-root"> - <ul role="list"> - {events.map((e, idx) => { - const isLast = events.length - 1 === idx; - return ( - <li - class="hover:bg-gray-200 p-2 rounded cursor-pointer" - onClick={() => { - setSelected(e); - }} - > - <div class="relative pb-6"> - {!isLast ? ( - <span - class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200" - aria-hidden="true" - ></span> - ) : undefined} - <div class="relative flex space-x-3"> - {(() => { - switch (e.type) { - case "aml-form": { - switch (e.state) { - case AmlExchangeBackend.AmlState.normal: { - return ( - <div> - <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> - Normal - </span> - <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> - {e.threshold.currency}{" "} - {Amounts.stringifyValue(e.threshold)} - </span> - </div> - ); - } - case AmlExchangeBackend.AmlState.pending: { - return ( - <div> - <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> - Pending - </span> - <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> - {e.threshold.currency}{" "} - {Amounts.stringifyValue(e.threshold)} - </span> - </div> - ); - } - case AmlExchangeBackend.AmlState.frozen: { - return ( - <div> - <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> - Frozen - </span> - <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> - {e.threshold.currency}{" "} - {Amounts.stringifyValue(e.threshold)} - </span> - </div> - ); - } - } - } - case "kyc-collection": { - return ( - // <ArrowDownCircleIcon class="h-8 w-8 text-green-700" /> - <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="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> - </svg> - ); - } - case "kyc-expiration": { - // return <ClockIcon class="h-8 w-8 text-gray-700" />; - return <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 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> - </svg> - - } - } - })()} - <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> - <div> - <p class="text-sm text-gray-900">{e.title}</p> - </div> - <div class="whitespace-nowrap text-right text-sm text-gray-500"> - {e.when.t_ms === "never" ? ( - "never" - ) : ( - <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}> - {format(e.when.t_ms, "dd MMM yyyy")} - </time> - )} + <ShowTimeline history={events} onSelect={(e) => { + switch (e.type) { + case "aml-form": { + const { justification, metadata } = e + setShowForm({ justification, metadata }) + break; + } + case "kyc-collection": + case "kyc-expiration": { + setSelected(e.when); + break; + } + case "aml-form-error": + } + }} /> + {/* {selected && <ShowEventDetails event={selected} />} */} + {selected && <ShowConsolidated history={events} until={selected} />} + </div> + ); +} + +function AmlStateBadge({ state }: { state: AmlExchangeBackend.AmlState }): VNode { + switch (state) { + case AmlExchangeBackend.AmlState.normal: { + return ( + <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> + Normal + </span> + ); + } + case AmlExchangeBackend.AmlState.pending: { + return ( + <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> + Pending + </span> + ); + } + case AmlExchangeBackend.AmlState.frozen: { + return ( + <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> + Frozen + </span> + ); + } + } + assertUnreachable(state) +} + +function ShowTimeline({ history, onSelect }: { onSelect: (e: AmlEvent) => void, history: AmlEvent[] }): VNode { + return <div class="flow-root"> + <ul role="list"> + {history.map((e, idx) => { + const isLast = history.length - 1 === idx; + return ( + <li + data-ok={e.type !== "aml-form-error"} + class="hover:bg-gray-200 p-2 rounded data-[ok=true]:cursor-pointer" + onClick={() => { + onSelect(e); + }} + > + <div class="relative pb-6"> + {!isLast ? ( + <span + class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200" + aria-hidden="true" + ></span> + ) : undefined} + <div class="relative flex space-x-3"> + {(() => { + switch (e.type) { + case "aml-form-error": + case "aml-form": { + return <div> + <AmlStateBadge state={e.state} /> + <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> + {e.threshold.currency}{" "} + {Amounts.stringifyValue(e.threshold)} + </span> </div> - </div> + } + case "kyc-collection": { + return ( + // <ArrowDownCircleIcon class="h-8 w-8 text-green-700" /> + <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="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + ); + } + case "kyc-expiration": { + // return <ClockIcon class="h-8 w-8 text-gray-700" />; + return <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 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> + </svg> + + } + } + assertUnreachable(e) + })()} + <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> + {e.type === "aml-form" ? + <span + // href={Pages.newFormEntry.url({ account })} + class="block 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" + > + {e.title} + </span> + : + <p class="text-sm text-gray-900">{e.title}</p> + } + <div class="whitespace-nowrap text-right text-sm text-gray-500"> + {e.when.t_ms === "never" ? ( + "never" + ) : ( + <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}> + {format(e.when.t_ms, "dd MMM yyyy")} + </time> + )} </div> </div> - </li> - ); - })} - </ul> - </div> - {selected && <ShowEventDetails event={selected} />} - {selected && <ShowConsolidated history={events} until={selected.when} />} - </div> - ); + </div> + </div> + </li> + ); + })} + </ul> + </div> + } function ShowEventDetails({ event }: { event: AmlEvent }): VNode { diff --git a/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx new file mode 100644 index 000000000..0355d5a31 --- /dev/null +++ b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx @@ -0,0 +1,42 @@ +/* + This file is part of GNU Taler + (C) 2022 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import * as tests from "@gnu-taler/web-util/testing"; +import { AmlExchangeBackend } from "../types.js"; +import { + CasesUI as TestedComponent, +} from "./Cases.js"; +import { AmountString } from "@gnu-taler/taler-util"; + +export default { + title: "cases", +}; + +export const OneRow = tests.createExample(TestedComponent, { + filter: AmlExchangeBackend.AmlState.normal, + onChangeFilter: () => null, + records: [{ + current_state: AmlExchangeBackend.AmlState.normal, + h_payto: "QWEQWEQWEQWE", + rowid: 1, + threshold: "USD:1" as AmountString + }] +}); diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx index 64cacf68c..32e162e5b 100644 --- a/packages/aml-backoffice-ui/src/pages/Cases.tsx +++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -1,4 +1,4 @@ -import { TalerError, TranslatedString, assertUnreachable } from "@gnu-taler/taler-util"; +import { TalerError, TalerExchangeApi, TranslatedString, assertUnreachable } from "@gnu-taler/taler-util"; import { ErrorLoading, Loading, useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -10,13 +10,152 @@ import { AmlExchangeBackend } from "../types.js"; import { Officer } from "./Officer.js"; import { amlStateConverter } from "./ShowConsolidated.js"; -export function Cases() { +export function CasesUI({ records, filter, onChangeFilter, onFirstPage, onNext }: { onFirstPage?: () => void, onNext?: () => void, filter: AmlExchangeBackend.AmlState, onChangeFilter: (f: AmlExchangeBackend.AmlState) => void, records: TalerExchangeApi.AmlRecord[] }): VNode { const { i18n } = useTranslationContext(); const form = createNewForm<{ state: AmlExchangeBackend.AmlState }>(); - const initial = AmlExchangeBackend.AmlState.pending; - const [stateFilter, setStateFilter] = useState(initial); + return <div> + <div class="sm:flex sm:items-center"> + <div class="px-2 sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate> + Cases + </i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-700"> + <i18n.Translate> + A list of all the account with the status + </i18n.Translate> + </p> + </div> + <div class="px-2"> + <form.Provider + initialValue={{ state: filter }} + onUpdate={(v) => { + onChangeFilter(v.state ?? filter); + }} + onSubmit={(v) => { }} + > + <form.InputChoiceHorizontal + name="state" + label={i18n.str`Filter`} + converter={amlStateConverter} + choices={[ + { + label: "Pending" as TranslatedString, + value: AmlExchangeBackend.AmlState.pending, + }, + { + label: "Frozen" as TranslatedString, + value: AmlExchangeBackend.AmlState.frozen, + }, + { + label: "Normal" as TranslatedString, + value: AmlExchangeBackend.AmlState.normal, + }, + ]} + /> + + </form.Provider> + </div> + </div> + <div class="mt-8 flow-root"> + <div class="overflow-x-auto"> + {!records.length ? ( + <div>empty result </div> + ) : ( + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate> + Account Id + </i18n.Translate> + </th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate> + Status + </i18n.Translate> + </th> + <th + scope="col" + class="sm:hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate> + Threshold + </i18n.Translate> + </th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200 bg-white"> + {records.map((r) => { + return ( + <tr class="hover:bg-gray-100 "> + <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 "> + <div class="text-gray-900"> + <a + href={Pages.account.url({ account: r.h_payto })} + class="text-indigo-600 hover:text-indigo-900" + > + {r.h_payto.substring(0, 16)}... + </a> + </div> + </td> + <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500"> + {((state: AmlExchangeBackend.AmlState): VNode => { + switch (state) { + case AmlExchangeBackend.AmlState.normal: { + return ( + <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> + Normal + </span> + ); + } + case AmlExchangeBackend.AmlState.pending: { + return ( + <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> + Pending + </span> + ); + } + case AmlExchangeBackend.AmlState.frozen: { + return ( + <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> + Frozen + </span> + ); + } + } + })(r.current_state)} + </td> + <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900"> + {r.threshold} + </td> + </tr> + ); + })} + </tbody> + </table> + <Pagination onFirstPage={onFirstPage} onNext={onNext} /> + </div> + )} + </div> + </div> + </div> + +} + + +export function Cases() { + const [stateFilter, setStateFilter] = useState(AmlExchangeBackend.AmlState.pending); const list = useCases(stateFilter); @@ -38,150 +177,26 @@ export function Cases() { const { records } = list.data.body - return ( - <div> - <div class="px-4 sm:px-6 lg:px-8"> - <div class="sm:flex sm:items-center"> - <div class="sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate> - Cases - </i18n.Translate> - </h1> - <p class="mt-2 text-sm text-gray-700"> - <i18n.Translate> - A list of all the account with the status - </i18n.Translate> - </p> - </div> - <form.Provider - initialValue={{ state: stateFilter }} - onUpdate={(v) => { - setStateFilter(v.state ?? initial); - }} - onSubmit={(v) => { }} - > - <form.InputChoiceHorizontal - name="state" - label={"Filter" as TranslatedString} - converter={amlStateConverter} - choices={[ - { - label: "Pending" as TranslatedString, - value: AmlExchangeBackend.AmlState.pending, - }, - { - label: "Frozen" as TranslatedString, - value: AmlExchangeBackend.AmlState.frozen, - }, - { - label: "Normal" as TranslatedString, - value: AmlExchangeBackend.AmlState.normal, - }, - ]} - /> - </form.Provider> - </div> - <div class="mt-8 flow-root"> - <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> - {!records.length ? ( - <div>empty result </div> - ) : ( - <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> - <Pagination /> - <table class="min-w-full divide-y divide-gray-300"> - <thead> - <tr> - <th - scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - > - Account Id - </th> - <th - scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - > - Status - </th> - <th - scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - > - Threshold - </th> - </tr> - </thead> - <tbody class="divide-y divide-gray-200 bg-white"> - {records.map((r) => { - return ( - <tr class="hover:bg-gray-100 "> - <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 "> - <div class="text-gray-900"> - <a - href={Pages.account.url({ account: r.h_payto })} - class="text-indigo-600 hover:text-indigo-900" - > - {r.h_payto} - </a> - </div> - </td> - <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500"> - {((state: AmlExchangeBackend.AmlState): VNode => { - switch (state) { - case AmlExchangeBackend.AmlState.normal: { - return ( - <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> - Normal - </span> - ); - } - case AmlExchangeBackend.AmlState.pending: { - return ( - <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> - Pending - </span> - ); - } - case AmlExchangeBackend.AmlState.frozen: { - return ( - <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> - Frozen - </span> - ); - } - } - })(r.current_state)} - </td> - <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900"> - {r.threshold} - </td> - </tr> - ); - })} - </tbody> - </table> - <Pagination /> - </div> - )} - </div> - </div> - </div> - </div> - ); + return <CasesUI + records={records} + onFirstPage={list.pagination && !list.pagination.isFirstPage ? list.pagination.reset : undefined} + onNext={list.pagination && !list.pagination.isLastPage ? list.pagination.loadMore : undefined} + filter={stateFilter} + onChangeFilter={setStateFilter} + /> } export const PeopleIcon = () => <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="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /> + <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /> </svg> export const HomeIcon = () => <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="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> + <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> </svg> export const ChevronRightIcon = () => <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="M8.25 4.5l7.5 7.5-7.5 7.5" /> + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> </svg> @@ -190,92 +205,27 @@ export const ArrowRightIcon = () => <svg xmlns="http://www.w3.org/2000/svg" fill </svg> -function Pagination() { +function Pagination({ onFirstPage, onNext }: { onFirstPage?: () => void, onNext?: () => void, }) { + const { i18n } = useTranslationContext() return ( - <nav class="flex items-center justify-between px-4 sm:px-0"> - <div class="-mt-px flex w-0 flex-1"> - <a - href="#" - class="inline-flex items-center border-t-2 border-transparent pr-1 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" - > - <svg - class="mr-3 h-5 w-5 text-gray-400" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M18 10a.75.75 0 01-.75.75H4.66l2.1 1.95a.75.75 0 11-1.02 1.1l-3.5-3.25a.75.75 0 010-1.1l3.5-3.25a.75.75 0 111.02 1.1l-2.1 1.95h12.59A.75.75 0 0118 10z" - clip-rule="evenodd" - /> - </svg> - Previous - </a> - </div> - <div class="hidden md:-mt-px md:flex"> - <a - href="#" - class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" + <nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" aria-label="Pagination"> + <div class="flex flex-1 justify-between sm:justify-end"> + <button + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onFirstPage} + onClick={onFirstPage} > - 1 - </a> - {/* <!-- Current: "border-indigo-500 text-indigo-600", Default: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" --> */} - <a - href="#" - class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500" - aria-current="page" + <i18n.Translate>First page</i18n.Translate> + </button> + <button + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onNext} + onClick={onNext} > - 2 - </a> - <a - href="#" - class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" - > - 3 - </a> - <span class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500"> - ... - </span> - <a - href="#" - class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" - > - 8 - </a> - <a - href="#" - class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" - > - 9 - </a> - <a - href="#" - class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" - > - 10 - </a> - </div> - <div class="-mt-px flex w-0 flex-1 justify-end"> - <a - href="#" - class="inline-flex items-center border-t-2 border-transparent pl-1 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" - > - Next - <svg - class="ml-3 h-5 w-5 text-gray-400" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M2 10a.75.75 0 01.75-.75h12.59l-2.1-1.95a.75.75 0 111.02-1.1l3.5 3.25a.75.75 0 010 1.1l-3.5 3.25a.75.75 0 11-1.02-1.1l2.1-1.95H2.75A.75.75 0 012 10z" - clip-rule="evenodd" - /> - </svg> - </a> + <i18n.Translate>Next</i18n.Translate> + </button> </div> </nav> - ); + + ) } diff --git a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx index 95b1f35c4..214c17648 100644 --- a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx +++ b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx @@ -1,13 +1,11 @@ +import { Amounts, TalerExchangeApi, TalerProtocolTimestamp, TranslatedString } from "@gnu-taler/taler-util"; +import { LocalNotificationBanner, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { AntiMoneyLaunderingForm, allForms } from "./AntiMoneyLaunderingForm.js"; -import { Pages } from "../pages.js"; -import { NiceForm } from "../NiceForm.js"; -import { AbsoluteTime, Amounts, TalerExchangeApi, TalerProtocolTimestamp, TranslatedString } from "@gnu-taler/taler-util"; -import { AmlExchangeBackend } from "../types.js"; +import { useExchangeApiContext } from "../context/config.js"; import { useOfficer } from "../hooks/useOfficer.js"; +import { Pages } from "../pages.js"; +import { AntiMoneyLaunderingForm, allForms } from "./AntiMoneyLaunderingForm.js"; import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; -import { useExchangeApiContext } from "../context/config.js"; -import { LocalNotificationBanner, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; export function NewFormEntry({ account, @@ -31,19 +29,13 @@ export function NewFormEntry({ return <HandleAccountNotReady officer={officer} />; } - const selectedForm = Number.parseInt(type ?? "0", 10); - if (Number.isNaN(selectedForm)) { - return <div>WHAT! {type}</div>; - } - - return ( <Fragment> <LocalNotificationBanner notification={notification} /> <AntiMoneyLaunderingForm account={account} - selectedForm={selectedForm} + formId={type} onSubmit={async (justification, new_state, new_threshold) => { const decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig"> = { @@ -56,27 +48,29 @@ export function NewFormEntry({ } await handleError(async () => { const resp = await api.addDecisionDetails(officer.account, decision); - if (resp.type === "fail") { - switch (resp.case) { - case "unauthorized": return notify({ - type: "error", - title: i18n.str`Wrong credentials for "${officer.account}"`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "officer-or-account-not-found": return notify({ - type: "error", - title: i18n.str`Officer or account not found`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "officer-disabled-or-recent-decision": return notify({ - type: "error", - title: i18n.str`Officer disabled or more recent decision was already submitted.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - } + if (resp.type === "ok") { + window.location.href = Pages.cases.url; + return; + } + switch (resp.case) { + case "unauthorized": return notify({ + type: "error", + title: i18n.str`Wrong credentials for "${officer.account}"`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "officer-or-account-not-found": return notify({ + type: "error", + title: i18n.str`Officer or account not found`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "officer-disabled-or-recent-decision": return notify({ + type: "error", + title: i18n.str`Officer disabled or more recent decision was already submitted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) } }) }} @@ -92,10 +86,10 @@ function SelectForm({ account }: { account: string }) { {allForms.map((form, idx) => { return ( <a - href={Pages.newFormEntry.url({ account, type: String(idx) })} + href={Pages.newFormEntry.url({ account, type: form.id })} class="m-4 block rounded-md w-fit border-0 p-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-600" > - {form.name} + {form.label} </a> ); })} diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx index 1a86e8e98..dc073a5f5 100644 --- a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx +++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx @@ -32,25 +32,74 @@ export default { }; export const WithEmptyHistory = tests.createExample(TestedComponent, { - history: getEventsFromAmlHistory([],[]), + history: getEventsFromAmlHistory([], []), until: AbsoluteTime.now() }); export const WithSomeEvents = tests.createExample(TestedComponent, { - history: getEventsFromAmlHistory([{ - decider_pub: "123", - decision_time: { t_s: 1 }, - justification: "yes", - new_state: 1, - new_threshold: "USD:10", - }],[{ + history: getEventsFromAmlHistory([ + { + "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", + "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}", + "new_threshold": "STATER:0", + "new_state": 1, + "decision_time": { + "t_s": 1700208199 + } + }, + { + "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", + "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}", + "new_threshold": "STATER:0", + "new_state": 1, + "decision_time": { + "t_s": 1700208211 + } + }, + { + "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", + "justification": "{\"index\":0,\"name\":\"Simple comment\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700207199558},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"test\"}}", + "new_threshold": "STATER:0", + "new_state": 1, + "decision_time": { + "t_s": 1700208220 + } + }, + { + "decider_pub": "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", + "justification": "{\"index\":4,\"name\":\"Declaration for trusts (902.13e)\",\"value\":{\"fullName\":\"loggedIn_user_fullname\",\"when\":{\"t_ms\":1700208362854},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"contractingPartner\":\"f\",\"knownAs\":\"a\",\"trust\":{\"name\":\"b\",\"type\":\"discretionary\",\"revocability\":\"irrevocable\"}}}", + "new_threshold": "STATER:0", + "new_state": 1, + "decision_time": { + "t_s": 1700208385 + } + }, + { + "decider_pub": "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG", + "justification": "{\"id\":\"simple_comment\",\"label\":\"Simple comment\",\"version\":1,\"value\":{\"when\":{\"t_ms\":1700488420810},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"qwe\"}}", + "new_threshold": "STATER:0", + "new_state": 1, + "decision_time": { + "t_s": 1700488423 + } + }, + { + "decider_pub": "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG", + "justification": "{\"id\":\"simple_comment\",\"label\":\"Simple comment\",\"version\":1,\"value\":{\"when\":{\"t_ms\":1700488671251},\"state\":1,\"threshold\":{\"currency\":\"STATER\",\"fraction\":0,\"value\":0},\"comment\":\"asd asd asd \"}}", + "new_threshold": "STATER:0", + "new_state": 1, + "decision_time": { + "t_s": 1700488677 + } + } + ], [{ collection_time: AbsoluteTime.toProtocolTimestamp( AbsoluteTime.subtractDuraction(AbsoluteTime.now(), Duration.fromPrettyString("1d")) ), - expiration_time: { t_s: "never"}, + expiration_time: { t_s: "never" }, provider_section: "asd", attributes: { - email: "sebasjm@qwe.com" + email: "sebasjm@qwdde.com" } }]), until: AbsoluteTime.now() diff --git a/packages/aml-backoffice-ui/src/pages/index.stories.ts b/packages/aml-backoffice-ui/src/pages/index.stories.ts index e31e13a28..afe73227a 100644 --- a/packages/aml-backoffice-ui/src/pages/index.stories.ts +++ b/packages/aml-backoffice-ui/src/pages/index.stories.ts @@ -1,2 +1,3 @@ export * as a1 from "./ShowConsolidated.stories.js"; export * as a2 from "./AntiMoneyLaunderingForm.stories.js"; +export * as a3 from "./Cases.stories.js"; diff --git a/packages/aml-backoffice-ui/src/route.ts b/packages/aml-backoffice-ui/src/route.ts index 091b92d5c..f515a590a 100644 --- a/packages/aml-backoffice-ui/src/route.ts +++ b/packages/aml-backoffice-ui/src/route.ts @@ -1,8 +1,20 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { createHashHistory } from "history"; -import { h as create, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -const history = createHashHistory(); +import { ComponentChildren, h as create, createContext, VNode } from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; + +type ContextType = { + onChange: (listener: () => void) => VoidFunction +} +const nullChangeListener = { onChange: () => () => { } } +const Context = createContext<ContextType>(nullChangeListener); + +export const usePathChangeContext = (): ContextType => useContext(Context); + +export function HashPathProvider({ children }: { children: ComponentChildren }): VNode { + const history = createHashHistory(); + return create(Context.Provider, { value: { onChange: history.listen }, children }, children) +} type PageDefinition<DynamicPart extends Record<string, string>> = { pattern: string; @@ -81,8 +93,9 @@ type Location = { }; export function useCurrentLocation(pageList: Array<PageEntry<any>>): Location | undefined { const [currentLocation, setCurrentLocation] = useState<Location | null | undefined>(null); + const path = usePathChangeContext(); useEffect(() => { - return history.listen(() => { + return path.onChange(() => { const result = doSync(window.location.hash, new URLSearchParams(window.location.search), pageList); setCurrentLocation(result); }); @@ -95,8 +108,9 @@ export function useCurrentLocation(pageList: Array<PageEntry<any>>): Location | export function useChangeLocation() { const [location, setLocation] = useState(window.location.hash) + const path = usePathChangeContext() useEffect(() => { - return history.listen(() => { + return path.onChange(() => { setLocation(window.location.hash) }); }, []); diff --git a/packages/aml-backoffice-ui/src/stories.test.ts b/packages/aml-backoffice-ui/src/stories.test.ts index 3b1d0267d..eca66cb18 100644 --- a/packages/aml-backoffice-ui/src/stories.test.ts +++ b/packages/aml-backoffice-ui/src/stories.test.ts @@ -18,7 +18,7 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { setupI18n } from "@gnu-taler/taler-util"; +import { TalerExchangeApi, setupI18n } from "@gnu-taler/taler-util"; import { parseGroupImport } from "@gnu-taler/web-util/browser"; import * as tests from "@gnu-taler/web-util/testing"; @@ -26,12 +26,13 @@ import * as tests from "@gnu-taler/web-util/testing"; import * as pages from "./pages/index.stories.js"; import { ComponentChildren, Fragment, VNode, h as create } from "preact"; +import { ExchangeApiContextTesting } from "./context/config.js"; // import { BackendStateProviderTesting } from "./context/backend.js"; setupI18n("en", { en: {} }); describe("All the examples:", () => { - const cms = parseGroupImport({pages}); + const cms = parseGroupImport({ pages }); cms.forEach((group) => { describe(`Example for group "${group.title}:"`, () => { group.list.forEach((component) => { @@ -47,10 +48,24 @@ describe("All the examples:", () => { }); }); + function DefaultTestingContext({ children, }: { children: ComponentChildren; }): VNode { - return create(Fragment, {}); + const config: TalerExchangeApi.ExchangeVersionResponse = { + currency: "ARS", + currency_specification: { + alt_unit_names: {}, + name: "ARS", + num_fractional_input_digits: 2, + num_fractional_normal_digits: 2, + num_fractional_trailing_zero_digits: 2 + }, + name: "taler-exchange", + supported_kyc_requirements: [], + version: "asd", + } + return create(ExchangeApiContextTesting, { config, children }); } diff --git a/packages/aml-backoffice-ui/src/stories.tsx b/packages/aml-backoffice-ui/src/stories.tsx index 5ef54309c..7685195e5 100644 --- a/packages/aml-backoffice-ui/src/stories.tsx +++ b/packages/aml-backoffice-ui/src/stories.tsx @@ -26,16 +26,40 @@ import * as pages from "./pages/index.stories.js"; import { renderStories } from "@gnu-taler/web-util/browser"; import "./scss/main.css"; +import { h, ComponentChildren, FunctionComponent, VNode } from "preact"; +import { ExchangeApiContextTesting } from "./context/config.js"; function main(): void { renderStories( - {pages}, + { pages }, { strings, + getWrapperForGroup }, ); } +function getWrapperForGroup(): FunctionComponent { + return function All({ children }: { children?: ComponentChildren }): VNode { + return <ExchangeApiContextTesting + config={{ + currency: "ARS", + currency_specification: { + alt_unit_names: {}, + name: "ARS", + num_fractional_input_digits: 2, + num_fractional_normal_digits: 2, + num_fractional_trailing_zero_digits: 2 + }, + name: "taler-exchange", + supported_kyc_requirements: [], + version: "asd", + }}> + {children} + </ExchangeApiContextTesting> + } +} + if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", main); } else { diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx index 92c379459..12babf39a 100644 --- a/packages/web-util/src/forms/DefaultForm.tsx +++ b/packages/web-util/src/forms/DefaultForm.tsx @@ -9,7 +9,6 @@ export interface FlexibleForm<T extends object> { design: DoubleColumnForm; behavior: (form: Partial<T>) => FormState<T>; } - export function DefaultForm<T extends object>({ initial, onUpdate, diff --git a/packages/web-util/src/stories.tsx b/packages/web-util/src/stories.tsx index 4edbf83b5..d9c2406eb 100644 --- a/packages/web-util/src/stories.tsx +++ b/packages/web-util/src/stories.tsx @@ -184,8 +184,8 @@ function ExampleList({ backgroundColor: isSelected ? "green" : i % 2 - ? "lightgray" - : "lightblue", + ? "lightgray" + : "lightblue", marginLeft: "1em", padding: 4, cursor: "pointer", @@ -395,10 +395,10 @@ function folder(groupName: string, value: ComponentOrFolder): ComponentItem[] { try { title = typeof value === "object" && - typeof value.default === "object" && - value.default !== undefined && - "title" in value.default && - typeof value.default.title === "string" + typeof value.default === "object" && + value.default !== undefined && + "title" in value.default && + typeof value.default.title === "string" ? value.default.title : undefined; } catch (e) { @@ -430,12 +430,12 @@ function Application({ examplesInGroups, getWrapperForGroup, }: Props): VNode { + const url = new URL(window.location.href); const initialSelection = getSelectionFromLocationHash( - location.hash, + url.hash, examplesInGroups, ); - const url = new URL(window.location.href); const currentLang = url.searchParams.get("lang") || "en"; if (!langs["en"]) { @@ -448,15 +448,15 @@ function Application({ ); const [sidebarWidth, setSidebarWidth] = useState(200); useEffect(() => { - if (location.hash) { - const hash = location.hash.substring(1); + if (url.hash) { + const hash = url.hash.substring(1); const found = document.getElementById(hash); if (found) { setTimeout(() => { found.scrollIntoView({ block: "center", }); - }, 10); + }, 50); } } }, []); @@ -500,11 +500,11 @@ function Application({ ))} <hr /> </SideBar> - <ResizeHandle + {/* <ResizeHandle onUpdate={(x) => { setSidebarWidth((s) => s + x); }} - /> + /> */} <Content> <ErrorReport selected={selected}> <PreventLinkNavigation> |