taler-typescript-core

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

commit a5f5a798953c761dc752dea7fdb774aeff2adda7
parent dd9dc8c523334278a532f8eb1b3645eeb6799a6a
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu,  3 Apr 2025 11:41:59 -0300

fix #9604

Diffstat:
Mpackages/aml-backoffice-ui/src/Routing.tsx | 16++++++++++++++++
Mpackages/aml-backoffice-ui/src/hooks/decision-request.ts | 19++++++++++++-------
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 296++++++++++++++++++++++++++++++++++---------------------------------------------
Apackages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx | 51+++++++++++++++++++++++++--------------------------
Mpackages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx | 66++++++++++++++++++++----------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Summary.tsx | 17++---------------
Mpackages/web-util/src/forms/fields/InputChoiceStacked.tsx | 4++--
Mpackages/web-util/src/forms/fields/InputFile.tsx | 108+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/web-util/src/forms/fields/InputLine.tsx | 2+-
Mpackages/web-util/src/forms/fields/InputSelectOne.tsx | 4+++-
Mpackages/web-util/src/forms/fields/InputToggle.tsx | 2+-
Mpackages/web-util/src/forms/forms-ui.tsx | 27++++++++++++++++++++++-----
Mpackages/web-util/src/forms/gana/VQF_902_1_customer.stories.tsx | 22++++++++++++++++++++++
14 files changed, 579 insertions(+), 320 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx @@ -43,6 +43,7 @@ import { AmlDecisionRequestWizard, WizardSteps, } from "./pages/decision/AmlDecisionRequestWizard.js"; +import { ShowCollectedInfo } from "./pages/ShowCollectedInfo.js"; export function Routing(): VNode { const session = useOfficer(); @@ -128,6 +129,10 @@ export const privatePages = { /\/decide_new\/(?<cid>[a-zA-Z0-9]+)\/(?<payto>[a-zA-Z0-9]+)/, ({ cid, payto }) => `#/decide_new/${cid}/${payto}`, ), + showCollectedInfo: urlPattern<{ cid: string; rowId: string }>( + /\/show-collected\/(?<cid>[a-zA-Z0-9]+)\/(?<rowId>[0-9]+)/, + ({ cid, rowId }) => `#/show-collected/${cid}/${rowId}`, + ), measuresNew: urlPattern(/\/measures\/new/, () => "#/measures/new"), measures: urlPattern(/\/measures/, () => "#/measures"), search: urlPattern(/\/search/, () => "#/search"), @@ -293,6 +298,7 @@ function PrivateRouting(): VNode { return ( <CaseDetails account={location.values.cid} + routeToShowCollectedInfo={privatePages.showCollectedInfo} onNewDecision={(r) => { updateRequest(r); navigateTo( @@ -319,6 +325,16 @@ function PrivateRouting(): VNode { case "transfers": { return <Transfers routeToCaseById={privatePages.caseDetails} />; } + case "showCollectedInfo": { + return ( + <ShowCollectedInfo + routeToCaseById={privatePages.caseDetails} + routeToShowCollectedInfo={privatePages.showCollectedInfo} + account={location.values.cid} + rowId={Number.parseInt(location.values.rowId, 10)} + /> + ); + } default: assertUnreachable(location); diff --git a/packages/aml-backoffice-ui/src/hooks/decision-request.ts b/packages/aml-backoffice-ui/src/hooks/decision-request.ts @@ -49,7 +49,7 @@ export interface AccountAttributes { /** * Information that will be ask to the AML officer * in order to build up the decision. - * + * * With all of this we need to create a AmlDecisionRequest */ export interface DecisionRequest { @@ -98,7 +98,7 @@ export interface DecisionRequest { */ triggering_events: string[] | undefined; /** - * Custom unsupported events to be triggered + * Custom unsupported events to be triggered */ custom_events: string[] | undefined; } @@ -122,7 +122,10 @@ export const codecForDecisionRequest = (): Codec<DecisionRequest> => .property("justification", codecOptional(codecForString())) .property("accountName", codecOptional(codecForString())) .property("custom_events", codecOptional(codecForList(codecForString()))) - .property("triggering_events", codecOptional(codecForList(codecForString()))) + .property( + "triggering_events", + codecOptional(codecForList(codecForString())), + ) .property( "keep_investigating", codecOptionalDefault(codecForBoolean(), false), @@ -134,21 +137,23 @@ export const codecForDecisionRequest = (): Codec<DecisionRequest> => ) .build("DecisionRequest"); -const defaultDecisionRequest: DecisionRequest = { +export const DECISION_REQUEST_EMPTY: DecisionRequest = { deadline: undefined, + custom_properties: undefined, onExpire_measures: undefined, custom_events: undefined, + attributes: undefined, accountName: undefined, + triggering_events: undefined, justification: undefined, keep_investigating: false, new_measures: undefined, - triggering_events: undefined, properties: undefined, - attributes: undefined, - custom_properties: undefined, rules: undefined, }; +const defaultDecisionRequest: DecisionRequest = DECISION_REQUEST_EMPTY; + const DECISION_REQUEST_KEY = buildStorageKey( "aml-decision-request", codecForDecisionRequest(), diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -32,6 +32,7 @@ import { TalerError, TalerErrorDetail, TalerExchangeApi, + TalerFormAttributes, TranslatedString, } from "@gnu-taler/taler-util"; import { @@ -44,6 +45,7 @@ import { InternationalizationAPI, Loading, LocalNotificationBanner, + RouteDefinition, Time, UIHandlerId, useExchangeApiContext, @@ -57,7 +59,10 @@ import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { useAccountInformation } from "../hooks/account.js"; import { CustomMeasures, useCustomMeasures } from "../hooks/custom-measures.js"; -import { DecisionRequest } from "../hooks/decision-request.js"; +import { + DECISION_REQUEST_EMPTY, + DecisionRequest, +} from "../hooks/decision-request.js"; import { useAccountDecisions } from "../hooks/decisions.js"; import { useOfficer } from "../hooks/officer.js"; import { useServerMeasures } from "../hooks/server-info.js"; @@ -66,65 +71,55 @@ import { Officer } from "./Officer.js"; import { RulesInfo } from "./RulesInfo.js"; import { ShowConsolidated } from "./ShowConsolidated.js"; -export type AmlEvent = - | AmlFormEvent - | AmlFormEventError - | KycCollectionEvent - | KycExpirationEvent; +// export type AmlEvent = +// | AmlFormEvent +// | KycCollectionEvent +// | KycExpirationEvent; -type AmlFormEvent = { - type: "aml-form"; - when: AbsoluteTime; - title: TranslatedString; - justification: Justification; - metadata: FormMetadata; - state: TalerExchangeApi.AmlState; - threshold: AmountJson; -}; -type AmlFormEventError = { - type: "aml-form-error"; - when: AbsoluteTime; - title: TranslatedString; - justification: undefined; - metadata: undefined; - state: TalerExchangeApi.AmlState; - threshold: AmountJson; -}; -type KycCollectionEvent = { - type: "kyc-collection"; - when: AbsoluteTime; - title: TranslatedString; - values: object; - provider?: string; -}; -type KycExpirationEvent = { - type: "kyc-expiration"; - when: AbsoluteTime; - title: TranslatedString; - fields: string[]; -}; +// type AmlFormEvent = { +// type: "aml-form"; +// when: AbsoluteTime; +// title: TranslatedString; +// justification: Justification; +// metadata: FormMetadata; +// state: TalerExchangeApi.AmlState; +// threshold: AmountJson; +// }; +// type KycCollectionEvent = { +// type: "kyc-collection"; +// when: AbsoluteTime; +// title: TranslatedString; +// values: object; +// provider?: string; +// }; +// type KycExpirationEvent = { +// type: "kyc-expiration"; +// when: AbsoluteTime; +// title: TranslatedString; +// fields: string[]; +// }; -type WithTime = { when: AbsoluteTime }; +// type WithTime = { when: AbsoluteTime }; -function selectSooner(a: WithTime, b: WithTime) { - return AbsoluteTime.cmp(a.when, b.when); -} +// function selectSooner(a: WithTime, b: WithTime) { +// return AbsoluteTime.cmp(a.when, b.when); +// } -export function getEventsFromAmlHistory( - events: TalerExchangeApi.KycAttributeCollectionEvent[], - i18n: InternationalizationAPI, -): AmlEvent[] { - const ke = events.map((event) => { - return { - type: "kyc-collection", - title: i18n.str`User filled a form`, - when: AbsoluteTime.fromProtocolTimestamp(event.collection_time), - values: !event.attributes ? {} : event.attributes, - provider: event.provider_name, - } as AmlEvent; - }); - return ke.sort(selectSooner); -} +// export function getEventsFromAmlHistory( +// events: TalerExchangeApi.KycAttributeCollectionEvent[], +// i18n: InternationalizationAPI, +// ): AmlEvent[] { +// const ke = events.map((event) => { +// return { +// type: "kyc-collection", +// title: i18n.str`User filled a form`, +// when: AbsoluteTime.fromProtocolTimestamp(event.collection_time), +// values: !event.attributes ? {} : event.attributes, +// provider: event.provider_name, +// } as AmlEvent; +// }); +// return ke.sort(selectSooner); +// } type NewDecision = { request: Omit<Omit<AmlDecisionRequest, "justification">, "officer_sig">; @@ -133,21 +128,14 @@ type NewDecision = { export function CaseDetails({ account, + routeToShowCollectedInfo, onNewDecision, - // paytoString, }: { onNewDecision: (d: DecisionRequest) => void; + routeToShowCollectedInfo: RouteDefinition<{cid:string,rowId:string}>; account: string; - // paytoString?: PaytoString; }) { - const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now()); - // const [request, setDesicionRequest] = useState<NewDecision | undefined>( - // undefined, - // ); - // const [decisionWizardStep, setDecisionWizardStep] = - // useState<WizardSteps>("events"); - // const [selectMeasure, setSelectMeasure] = useState<boolean>(); - // const { config } = useExchangeApiContext(); + const [selected, setSelected] = useState<AbsoluteTime | undefined>(undefined); //AbsoluteTime.now()); const { i18n } = useTranslationContext(); const details = useAccountInformation(account); @@ -184,33 +172,20 @@ export function CaseDetails({ assertUnreachable(history); } } - const { details: accountDetails } = details.body; + const { details: collectionEvents } = details.body; const activeDecision = history.body.find((d) => d.is_active); const restDecisions = !activeDecision ? history.body : history.body.filter((d) => d.rowid !== activeDecision.rowid); - const events = getEventsFromAmlHistory(accountDetails, i18n); + // const events = getEventsFromAmlHistory(accountDetails, i18n); function ShortcutActionButtons(): VNode { return ( <div> <button onClick={async () => { - onNewDecision({ - deadline: undefined, - custom_properties: undefined, - onExpire_measures: undefined, - custom_events: undefined, - attributes: undefined, - accountName: undefined, - triggering_events: undefined, - justification: undefined, - keep_investigating: false, - new_measures: undefined, - properties: undefined, - rules: undefined, - }); + onNewDecision(DECISION_REQUEST_EMPTY); }} class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" > @@ -241,7 +216,9 @@ export function CaseDetails({ <ShortcutActionButtons /> - {selected && <ShowConsolidated history={events} until={selected} />} + {selected && ( + <ShowConsolidated history={collectionEvents} until={selected} /> + )} <div class="p-4"> <h1 class="text-base font-semibold leading-6 text-black"> <i18n.Translate>Collected information</i18n.Translate> @@ -252,14 +229,13 @@ export function CaseDetails({ </i18n.Translate> </p> </div> - {events.length === 0 ? ( + {collectionEvents.length === 0 ? ( <Attention title={i18n.str`The event list is empty`} type="warning" /> ) : ( <ShowTimeline - history={events} - onSelect={(time) => { - setSelected(time); - }} + account={account} + history={collectionEvents} + routeToShowCollectedInfo={routeToShowCollectedInfo} /> )} @@ -752,101 +728,82 @@ function AmlStateBadge({ state }: { state: TalerExchangeApi.AmlState }): VNode { function ShowTimeline({ history, - onSelect, + account, + routeToShowCollectedInfo, }: { - onSelect: (e: AbsoluteTime) => void; - history: AmlEvent[]; + account: string, + routeToShowCollectedInfo: RouteDefinition<{cid:string,rowId:string}>; + history: TalerExchangeApi.KycAttributeCollectionEvent[]; }): VNode { const { i18n } = useTranslationContext(); return ( <div class="flow-root"> <ul role="list"> {history.map((e, idx) => { - // const isLast = history.length - 1 === idx; + const values = e.attributes ?? {}; + const formId = values[TalerFormAttributes.FORM_ID] as + | string + | undefined; + return ( + <a href={routeToShowCollectedInfo.url({cid: account, rowId: String(e.rowid)})}> + <li key={idx} - data-ok={e.type !== "aml-form-error"} - class="hover:bg-gray-200 p-2 rounded data-[ok=true]:cursor-pointer" - onClick={() => { - onSelect(e.when); - }} + class="hover:bg-gray-200 p-2 rounded" > <div class="relative pb-3"> <span class="absolute left-3 top-5 -ml-px h-full w-1 bg-gray-200"></span> <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> - ); - } - 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" + {/* <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> + {!formId ? undefined : ( + <div> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="size-6" > - {e.title} - </span> - ) : ( - <p class="text-sm text-gray-900">{e.title}</p> - )} + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 0 1-2.25 2.25M16.5 7.5V18a2.25 2.25 0 0 0 2.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 0 0 2.25 2.25h13.5M6 7.5h3v3H6v-3Z" + /> + </svg> + <span>{formId}</span> + </div> + )} + <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> <div class="whitespace-nowrap text-right text-sm text-gray-500"> - {e.when.t_ms === "never" ? ( + {e.collection_time.t_s === "never" ? ( "never" ) : ( - <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}> - {format(e.when.t_ms, "dd MMM yyyy HH:mm:ss")} + <time + dateTime={format( + e.collection_time.t_s * 1000, + "dd MMM yyyy", + )} + > + {format( + e.collection_time.t_s * 1000, + "dd MMM yyyy HH:mm:ss", + )} </time> )} </div> @@ -854,13 +811,12 @@ function ShowTimeline({ </div> </div> </li> + </a> + ); })} <li - class="hover:bg-gray-200 p-2 rounded data-[ok=true]:cursor-pointer" - onClick={() => { - onSelect(AbsoluteTime.now()); - }} + class="hover:bg-gray-200 p-2 rounded" > <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> <svg @@ -878,7 +834,7 @@ function ShowTimeline({ /> </svg> <p class="text-sm text-gray-900"> - <i18n.Translate>Now </i18n.Translate> + <i18n.Translate>Now</i18n.Translate> </p> </div> </li> diff --git a/packages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx b/packages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx @@ -0,0 +1,265 @@ +import { + AmountJson, + Amounts, + assertUnreachable, + CurrencySpecification, + HttpStatusCode, + TalerError, + TalerFormAttributes, +} from "@gnu-taler/taler-util"; +import { + Attention, + FormMetadata, + FormUI, + Loading, + RouteDefinition, + useExchangeApiContext, + useForm, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { preloadedForms } from "../forms/index.js"; +import { useAccountInformation } from "../hooks/account.js"; +import { Officer } from "./Officer.js"; + +export function ShowCollectedInfo({ + routeToCaseById, + account, + routeToShowCollectedInfo, + rowId, +}: { + rowId: number; + account: string; + routeToShowCollectedInfo: RouteDefinition<{ cid: string; rowId: string }>; + routeToCaseById: RouteDefinition<{ cid: string }>; +}): VNode { + const { i18n } = useTranslationContext(); + + const details = useAccountInformation(account); + + if (!details) { + return <Loading />; + } + if (details instanceof TalerError) { + return <ErrorLoadingWithDebug error={details} />; + } + if (details.type === "fail") { + switch (details.case) { + case HttpStatusCode.Forbidden: + return ( + <Fragment> + <Attention type="danger" title={i18n.str`Operation denied`}> + <i18n.Translate> + This account signature is invalid, contact administrator or + create a new one. + </i18n.Translate> + </Attention> + <Officer /> + </Fragment> + ); + case HttpStatusCode.NotFound: + return ( + <Fragment> + <Attention type="danger" title={i18n.str`Operation denied`}> + <i18n.Translate> + The designated AML account is not known, contact administrator + or create a new one. + </i18n.Translate> + </Attention> + <Officer /> + </Fragment> + ); + case HttpStatusCode.Conflict: + return ( + <Fragment> + <Attention type="danger" title={i18n.str`Operation denied`}> + <i18n.Translate> + The designated AML account is not enabled, contact administrator + or create a new one. + </i18n.Translate> + </Attention> + <Officer /> + </Fragment> + ); + default: + assertUnreachable(details); + } + } + + const { details: history } = details.body; + const eventIndex = history.findIndex((h) => h.rowid === rowId); + const event = eventIndex === -1 ? undefined : history[eventIndex]; + if (!event) { + return <div>There is no collection event with id {rowId}</div>; + } + const hasNext = eventIndex < history.length - 1; + const hasPrevious = eventIndex > 0; + const previousId = hasPrevious ? history[eventIndex - 1].rowid : undefined; + const nextId = hasNext ? history[eventIndex + 1].rowid : undefined; + + if (!event.attributes) { + return ( + <div>the event referenced by rowId doesn't have any information</div> + ); + } + const FORM_ID = event.attributes[TalerFormAttributes.FORM_ID] as + | string + | undefined; + const FORM_VERSION = event.attributes[TalerFormAttributes.FORM_VERSION] as + | number + | undefined; + if (!FORM_ID) { + return <div>no form id in the collected information</div>; + } + const forms = preloadedForms(i18n); + + const formsWithId = forms.filter((f) => f.id === FORM_ID); + + if (!formsWithId.length) { + return <div>form not found with id {FORM_ID}</div>; + } + + let lastVersion = -1; + let lastForm: FormMetadata; + let correctVersionForm: FormMetadata | undefined; + formsWithId.forEach((f) => { + if (f.version > lastVersion) { + lastVersion = f.version; + lastForm = f; + } + if (f.version === FORM_VERSION) { + correctVersionForm = f; + } + }); + // list have at least one element, lastVerseion is initialized with -1 + // and version is always > 0 + const formToBeUsed = + correctVersionForm === undefined ? lastForm! : correctVersionForm; + + return ( + <ShowForm + key={eventIndex} + form={formToBeUsed} + account={account} + data={event.attributes ?? {}} + routeToCaseById={routeToCaseById} + expectedVersion={FORM_VERSION} + previousId={previousId} + nextId={nextId} + routeToShowCollectedInfo={routeToShowCollectedInfo} + /> + ); +} + +function ShowForm({ + form, + data, + account, + routeToCaseById, + expectedVersion, + previousId, + routeToShowCollectedInfo, + nextId, +}: { + form: FormMetadata; + data: object; + account: string; + routeToCaseById: RouteDefinition<{ cid: string }>; + expectedVersion: number | undefined; + routeToShowCollectedInfo: RouteDefinition<{ cid: string; rowId: string }>; + previousId?: number; + nextId?: number; +}): VNode { + + const { i18n } = useTranslationContext(); + const differentVersion = form.version !== expectedVersion; + const { model } = useForm(form.config, data); + + return ( + <Fragment> + {differentVersion ? ( + expectedVersion === undefined ? ( + <Attention type="warning" title={i18n.str`Different form version`}> + <i18n.Translate> + The collected information don't have the form version used. + </i18n.Translate> + </Attention> + ) : ( + <Attention type="warning" title={i18n.str`Different form version`}> + <i18n.Translate> + The collected information used the form version "{expectedVersion} + " but is not available. + </i18n.Translate> + </Attention> + ) + ) : undefined} + <div class="my-4"> + <a + href={routeToCaseById.url({ cid: account })} + class="mt-3 inline-flex w-full items-center justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:ml-3 sm:mt-0 sm:w-auto" + > + <i18n.Translate>Case details</i18n.Translate> + </a> + <a + href={previousId === undefined ? undefined : routeToShowCollectedInfo.url({ + cid: account, + rowId: String(previousId), + })} + data-disabled={previousId === undefined} + class="mt-3 inline-flex w-full items-center justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:ml-3 sm:mt-0 sm:w-auto data-[disabled=true]:bg-gray-500" + > + <i18n.Translate>Previous event</i18n.Translate> + </a> + <a + href={nextId === undefined ? undefined : routeToShowCollectedInfo.url({ + cid: account, + rowId: String(nextId), + })} + data-disabled={nextId === undefined} + class="mt-3 inline-flex w-full items-center justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 sm:ml-3 sm:mt-0 sm:w-auto data-[disabled=true]:bg-gray-500" + > + <i18n.Translate>Next event</i18n.Translate> + </a> + </div> + <FormUI design={form.config} model={model} disabled /> + </Fragment> + ); +} + +/** + * send to web-utils + * @param param0 + * @returns + */ +export function RenderAmount({ + value, + spec, + negative, + withColor, + hideSmall, +}: { + spec: CurrencySpecification; + value: AmountJson; + hideSmall?: boolean; + negative?: boolean; + withColor?: boolean; +}): VNode { + const neg = !!negative; // convert to true or false + + const { currency, normal, small } = Amounts.stringifyValueWithSpec( + value, + spec, + ); + + return ( + <span + data-negative={withColor ? neg : undefined} + class="whitespace-nowrap data-[negative=false]:text-green-600 data-[negative=true]:text-red-600" + > + {negative ? "- " : undefined} + {currency} {normal}{" "} + {!hideSmall && small && <sup class="-ml-1">{small}</sup>} + </span> + ); +} diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx @@ -26,7 +26,6 @@ import { } from "@gnu-taler/taler-util"; import { InternationalizationAPI } from "@gnu-taler/web-util/browser"; import * as tests from "@gnu-taler/web-util/testing"; -import { getEventsFromAmlHistory } from "./CaseDetails.js"; import { ShowConsolidated as TestedComponent } from "./ShowConsolidated.js"; export default { @@ -43,29 +42,29 @@ const nullTranslator: InternationalizationAPI = { Translate: () => undefined as unknown, }; -export const WithEmptyHistory = tests.createExample(TestedComponent, { - history: getEventsFromAmlHistory([], nullTranslator), - until: AbsoluteTime.now(), -}); +// export const WithEmptyHistory = tests.createExample(TestedComponent, { +// history: getEventsFromAmlHistory([], nullTranslator), +// until: AbsoluteTime.now(), +// }); -export const WithSomeEvents = tests.createExample(TestedComponent, { - history: getEventsFromAmlHistory( - [ - { - collection_time: AbsoluteTime.toProtocolTimestamp( - AbsoluteTime.subtractDuraction( - AbsoluteTime.now(), - Duration.fromPrettyString("1d"), - ), - ), - provider_name: "asd", - attributes: { - email: "sebasjm@qwdde.com", - }, - rowid: 1, - }, - ], - nullTranslator, - ), - until: AbsoluteTime.now(), -}); +// export const WithSomeEvents = tests.createExample(TestedComponent, { +// history: getEventsFromAmlHistory( +// [ +// { +// collection_time: AbsoluteTime.toProtocolTimestamp( +// AbsoluteTime.subtractDuraction( +// AbsoluteTime.now(), +// Duration.fromPrettyString("1d"), +// ), +// ), +// provider_name: "asd", +// attributes: { +// email: "sebasjm@qwdde.com", +// }, +// rowid: 1, +// }, +// ], +// nullTranslator, +// ), +// until: AbsoluteTime.now(), +// }); diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx @@ -13,7 +13,11 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; +import { + AbsoluteTime, + TalerExchangeApi, + TranslatedString, +} from "@gnu-taler/taler-util"; import { FormDesign, FormUI, @@ -24,7 +28,7 @@ import { import { format } from "date-fns"; import { VNode, h } from "preact"; import { useEffect } from "preact/hooks"; -import { AmlEvent } from "./CaseDetails.js"; +// import { AmlEvent } from "./CaseDetails.js"; /** * the exchange doesn't have a consistent api @@ -50,7 +54,7 @@ export function ShowConsolidated({ history, until, }: { - history: AmlEvent[]; + history: TalerExchangeApi.KycAttributeCollectionEvent[]; until: AbsoluteTime; }): VNode { const { i18n } = useTranslationContext(); @@ -102,11 +106,6 @@ export function ShowConsolidated({ } interface Consolidated { - // aml: { - // state: TalerExchangeApi.AmlState; - // threshold: AmountJson; - // since: AbsoluteTime; - // }; kyc: { [field: string]: { value: unknown; @@ -117,52 +116,27 @@ interface Consolidated { } export function getConsolidated( - history: AmlEvent[], + history: TalerExchangeApi.KycAttributeCollectionEvent[], when: AbsoluteTime, ): Consolidated { const initial: Consolidated = { - // aml: { - // state: TalerExchangeApi.AmlState.normal, - // threshold: { - // currency: "ARS", - // value: 1000, - // fraction: 0, - // }, - // since: AbsoluteTime.never(), - // }, kyc: {}, }; return history.reduce((prev, cur) => { - if (AbsoluteTime.cmp(when, cur.when) <= 0) { + const collectionTime = AbsoluteTime.fromProtocolTimestamp(cur.collection_time); + if (AbsoluteTime.cmp(when, collectionTime) <= 0) { return prev; } - switch (cur.type) { - case "kyc-expiration": { - // cur.fields.forEach((field) => { - // delete prev.kyc[field]; - // }); - break; - } - case "aml-form": { - // prev.aml = { - // since: cur.when, - // state: cur.state, - // threshold: cur.threshold, - // }; - break; - } - case "kyc-collection": { - Object.keys(cur.values).forEach((field) => { - const value = (cur.values as Record<string, unknown>)[field]; - prev.kyc[field] = { - value, - provider: cur.provider, - since: cur.when, - }; - }); - break; - } - } + + const formValues = cur.attributes ?? {} + Object.keys(formValues).forEach((field) => { + const value = (formValues as Record<string, unknown>)[field]; + prev.kyc[field] = { + value, + provider: cur.provider_name, + since: collectionTime, + }; + }); return prev; }, initial); } diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx @@ -19,7 +19,7 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; +import { DECISION_REQUEST_EMPTY, useCurrentDecisionRequest } from "../../hooks/decision-request.js"; import { useServerMeasures } from "../../hooks/server-info.js"; import { computeAvailableMesaures, @@ -98,20 +98,7 @@ export function Summary({ INVALID_ATTRIBUTES; function clearUp() { - updateDecision({ - custom_events: undefined, - custom_properties: undefined, - deadline: undefined, - triggering_events: undefined, - attributes: undefined, - accountName: undefined, - justification: undefined, - keep_investigating: false, - new_measures: undefined, - onExpire_measures: undefined, - properties: undefined, - rules: undefined, - }); + updateDecision(DECISION_REQUEST_EMPTY); onMove(undefined); } diff --git a/packages/web-util/src/forms/fields/InputChoiceStacked.tsx b/packages/web-util/src/forms/fields/InputChoiceStacked.tsx @@ -55,7 +55,7 @@ export function InputChoiceStacked<Choices>( <div class="space-y-4"> {choices.map((choice, idx) => { let clazz = - "border relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex sm:justify-between"; + "border relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex sm:justify-between data-[disabled=true]:cursor-not-allowed data-[disabled=true]:bg-gray-50 data-[disabled=true]:text-gray-500 "; if (choice.value === value) { clazz += " border-transparent border-indigo-600 ring-2 ring-indigo-600"; @@ -64,7 +64,7 @@ export function InputChoiceStacked<Choices>( } return ( - <label key={idx} class={clazz}> + <label key={idx} class={clazz} data-disabled={props.disabled}> <input type="radio" name="server-size" diff --git a/packages/web-util/src/forms/fields/InputFile.tsx b/packages/web-util/src/forms/fields/InputFile.tsx @@ -4,10 +4,12 @@ import { UIFormProps } from "../FormProvider.js"; import { FileFieldData } from "../forms-types.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { useTranslationContext } from "../../index.browser.js"; export function InputFile( props: { maxBites: number; accept?: string } & UIFormProps<FileFieldData>, ): VNode { + const { i18n } = useTranslationContext(); const { label, tooltip, required, help: propsHelp, maxBites, accept } = props; const { value, onChange } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); @@ -78,7 +80,9 @@ export function InputFile( for={String(props.name)} 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> + <span> + <i18n.Translate>Upload a file</i18n.Translate> + </span> <input id={String(props.name)} type="file" @@ -113,58 +117,70 @@ export function InputFile( </div> </div> ) : ( - <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> - {value.MIME_TYPE?.startsWith("image/") ? ( - <Fragment> - <img src={dataUri} class=" h-24 w-full object-cover relative" /> - {value.FILENAME ? ( - <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> - {value.FILENAME} - </div> - ) : ( - <Fragment /> - )} - </Fragment> - ) : ( - <div class="h-24 w-full object-cover relative p-2"> - <div class="flex flex-row"> - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - stroke-width="1.5" - stroke="currentColor" - class="size-6" - > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" - /> - </svg> - + <Fragment> + <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> + {value.MIME_TYPE?.startsWith("image/") ? ( + <Fragment> + <img src={dataUri} class=" h-24 w-full object-cover relative" /> {value.FILENAME ? ( - <div class=" flex justify-center text-xl items-center "> + <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> {value.FILENAME} </div> ) : ( - <div /> + <Fragment /> )} + </Fragment> + ) : ( + <div class="h-24 w-full object-cover relative p-2"> + <div class="flex flex-row"> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="size-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" + /> + </svg> + + {value.FILENAME ? ( + <div class=" flex justify-center text-xl items-center "> + {value.FILENAME} + </div> + ) : ( + <div /> + )} + </div> </div> - </div> - )} + )} - {!props.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={() => { - handleFile(undefined); - }} - > - Clear - </div> - )} - </div> + {!props.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={() => { + handleFile(undefined); + }} + > + Clear + </div> + )} + </div> + <a + class="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" + href={`data:${value.MIME_TYPE};${value.ENCODING},${value.CONTENTS}`} + download={value.FILENAME} + onClick={(e) => { + return false; + }} + > + <i18n.Translate>Download a copy.</i18n.Translate> + </a> + </Fragment> )} {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>} </div> diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx @@ -101,7 +101,7 @@ export function RenderAddon({ onClick={addon.onClick} data-left={!reverse} data-right={reverse} - class="relative -ml-px inline-flex items-center gap-x-1.5 data-[right=true]:rounded-r-md data-[left=true]: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" + class="relative -ml-px inline-flex items-center gap-x-1.5 data-[right=true]:rounded-r-md data-[left=true]: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 disabled:bg-gray-50 disabled:cursor-not-allowed" > {addon.children} </button> diff --git a/packages/web-util/src/forms/fields/InputSelectOne.tsx b/packages/web-util/src/forms/fields/InputSelectOne.tsx @@ -71,11 +71,12 @@ export function InputSelectOne<Choices>( {choiceMap[value as string]} <button type="button" + disabled={props.disabled} onClick={() => { onChange(undefined!); setDirty(true); }} - class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20" + class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20 disabled:cursor-not-allowed" > <svg viewBox="0 0 14 14" @@ -94,6 +95,7 @@ export function InputSelectOne<Choices>( ref={inputRef} type="text" value={filter ?? ""} + disabled={props.disabled} onChange={(e) => { setFilter(e.currentTarget.value); setDirty(true); diff --git a/packages/web-util/src/forms/fields/InputToggle.tsx b/packages/web-util/src/forms/fields/InputToggle.tsx @@ -50,7 +50,7 @@ export function InputToggle( }} > <span - data-state={isOn ? "on" : value === undefined ? "undefined" : "off"} + data-state={isOn ? "on" : value === undefined && threeState ? "undefined" : "off"} class="translate-x-6 data-[state=off]:translate-x-0 data-[state=undefined]:translate-x-3 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" ></span> </button> diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -23,7 +23,9 @@ import { convertFormConfigToUiField } from "./forms-utils.js"; export function DefaultForm<T>({ design, initial, + disabled, }: { + disabled?: boolean, design: FormDesign; initial: object; }): VNode { @@ -34,7 +36,7 @@ export function DefaultForm<T>({ return ( <div> <hr class="mt-3 mb-3" /> - <FormUI design={design} model={handler} /> + <FormUI design={design} model={handler} disabled={disabled} /> <hr class="mt-3 mb-3" /> <label> <input @@ -97,12 +99,14 @@ export function FormUI<T>({ name = DEFAULT_FORM_UI_NAME, design, model, + disabled, focus, }: { name?: string; design: FormDesign; model: FormModel; focus?: boolean; + disabled?: boolean; }): VNode { switch (design.type) { case "double-column": { @@ -116,7 +120,8 @@ export function FormUI<T>({ section={section} model={model} focus={focus} - /> + disabled={disabled} + /> ); }); return ( @@ -137,6 +142,7 @@ export function FormUI<T>({ fields={design.fields} model={model} focus={focus} + disabled={disabled} /> ); } @@ -148,13 +154,15 @@ export function DoubleColumnFormSectionUI<T>({ section, name, focus, - model: model, + model, + disabled, }: { sectionKey: string; name: string; model: FormModel; section: DoubleColumnFormSection; focus?: boolean; + disabled?: boolean; }): VNode { const { i18n } = useTranslationContext(); const fs = convertFormConfigToUiField( @@ -178,6 +186,7 @@ export function DoubleColumnFormSectionUI<T>({ key="fields" fields={fs} focus={focus} + disabled={disabled} /> ); } @@ -199,7 +208,8 @@ export function DoubleColumnFormSectionUI<T>({ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> <div class="p-3"> <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <RenderAllFieldsByUiConfig key="fields" fields={fs} focus={focus} /> + <RenderAllFieldsByUiConfig key="fields" fields={fs} focus={focus} disabled={disabled} + /> </div> </div> </div> @@ -210,13 +220,15 @@ export function DoubleColumnFormSectionUI<T>({ export function SingleColumnFormSectionUI<T>({ fields, name, - model: model, + model, focus, + disabled, }: { name: string; model: FormModel; fields: UIFormElementConfig[]; focus?: boolean; + disabled?: boolean; }): VNode { const { i18n } = useTranslationContext(); return ( @@ -229,6 +241,8 @@ export function SingleColumnFormSectionUI<T>({ <RenderAllFieldsByUiConfig fields={convertFormConfigToUiField(i18n, `root`, fields, model)} focus={focus} + disabled={disabled} + /> </div> </div> @@ -240,9 +254,11 @@ export function RenderAllFieldsByUiConfig({ focus, fields, hidden, + disabled, }: { fields: UIFormField[]; focus?: boolean; + disabled?: boolean; hidden?: boolean; }): VNode { return create( @@ -257,6 +273,7 @@ export function RenderAllFieldsByUiConfig({ focus: !!focus && i === 0, hidden: hidden || ("hidden" in field.properties && field.properties.hidden), + disabled: disabled, }; return <Component key={i} {...p} />; diff --git a/packages/web-util/src/forms/gana/VQF_902_1_customer.stories.tsx b/packages/web-util/src/forms/gana/VQF_902_1_customer.stories.tsx @@ -34,3 +34,25 @@ export const EmptyForm = tests.createExample(DefaultForm, { }); export default { title: "vqf_902_1_customer" }; + +export const DisabledForm = tests.createExample(DefaultForm, { + disabled: true, + initial: { + "CORRESPONDENCE_LANGUAGE": "en", + "CUSTOMER_TYPE": "NATURAL_PERSON", + "CUSTOMER_TYPE_VQF": "NATURAL_PERSON", + "DATE_OF_BIRTH": "1980-01-09", + "DOMICILE_ADDRESS": "asd", + "FORM_ID": "vqf_902_1_customer", + "FORM_VERSION": 1, + "FULL_NAME": "asd", + "NATIONALITY": "AR", + "PERSONAL_IDENTIFICATION_DOCUMENT_COPY": { + "CONTENTS": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=", + "ENCODING": "base64", + "FILENAME": "Glosario-Pagos-Minoristas.pdf", + "MIME_TYPE": "application/pdf" + } + }, + design: design_VQF_902_1_customer(i18n), +});