diff options
Diffstat (limited to 'packages/aml-backoffice-ui/src/pages/CaseDetails.tsx')
-rw-r--r-- | packages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 464 |
1 files changed, 464 insertions, 0 deletions
diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx new file mode 100644 index 000000000..1ad8c9453 --- /dev/null +++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -0,0 +1,464 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AbsoluteTime, + AmountJson, + Amounts, + Codec, + HttpStatusCode, + OperationFail, + OperationOk, + TalerError, + TalerErrorDetail, + TalerExchangeApi, + TranslatedString, + assertUnreachable, + buildCodecForObject, + codecForNumber, + codecForString, + codecOptional, +} from "@gnu-taler/taler-util"; +import { + DefaultForm, + ErrorLoading, + FlexibleForm, + InternationalizationAPI, + Loading, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { privatePages } from "../Routing.js"; +import { useCaseDetails } from "../hooks/useCaseDetails.js"; +import { ShowConsolidated } from "./ShowConsolidated.js"; +import { FormMetadata, useUiFormsContext } from "../context/ui-forms.js"; + +export type AmlEvent = + | AmlFormEvent + | AmlFormEventError + | 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 WithTime = { when: AbsoluteTime }; + +function selectSooner(a: WithTime, b: WithTime) { + return AbsoluteTime.cmp(a.when, b.when); +} + +function titleForJustification( + op: ReturnType<typeof parseJustification>, + i18n: InternationalizationAPI, +): TranslatedString { + if (op.type === "ok") { + return op.body.justification.label as TranslatedString; + } + switch (op.case) { + case "not-json": + return i18n.str`error: the justification is not a form`; + case "id-not-found": + return i18n.str`error: justification form's id not found`; + case "version-not-found": + return i18n.str`error: justification form's version not found`; + case "form-not-found": + return i18n.str`error: justification form not found`; + default: { + assertUnreachable(op.case); + } + } +} + +export function getEventsFromAmlHistory( + aml: TalerExchangeApi.AmlDecisionDetail[], + kyc: TalerExchangeApi.KycDetail[], + i18n: InternationalizationAPI, + forms: FormMetadata[], +): AmlEvent[] { + const ae: AmlEvent[] = aml.map((a) => { + const just = parseJustification(a.justification, forms); + return { + type: just.type === "ok" ? "aml-form" : "aml-form-error", + state: a.new_state, + threshold: Amounts.parseOrThrow(a.new_threshold), + title: titleForJustification(just, i18n), + 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" + ? "never" + : a.decision_time.t_s * 1000, + }, + } as AmlEvent; + }); + const ke = kyc.reduce((prev, k) => { + prev.push({ + type: "kyc-collection", + title: i18n.str`collection`, + when: AbsoluteTime.fromProtocolTimestamp(k.collection_time), + values: !k.attributes ? {} : k.attributes, + provider: k.provider_section, + }); + prev.push({ + type: "kyc-expiration", + title: i18n.str`expiration`, + when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time), + fields: !k.attributes ? [] : Object.keys(k.attributes), + }); + return prev; + }, [] as AmlEvent[]); + return ae.concat(ke).sort(selectSooner); +} + +export function CaseDetails({ account }: { account: string }) { + const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now()); + const [showForm, setShowForm] = useState<{ + justification: Justification; + metadata: FormMetadata; + }>(); + + const { i18n } = useTranslationContext(); + const details = useCaseDetails(account); + const {forms} = useUiFormsContext() + + if (!details) { + return <Loading />; + } + if (details instanceof TalerError) { + return <ErrorLoading error={details} />; + } + if (details.type === "fail") { + switch (details.case) { + case HttpStatusCode.Unauthorized: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + return <div />; + default: + assertUnreachable(details); + } + } + const { aml_history, kyc_attributes } = details.body; + + const events = getEventsFromAmlHistory(aml_history, kyc_attributes, i18n, forms); + + if (showForm !== undefined) { + return ( + <DefaultForm + readOnly={true} + initial={showForm.justification.value} + form={showForm.metadata as any} // FIXME: HERE + > + <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> + </DefaultForm> + ); + } + return ( + <div> + <a + href={privatePages.caseNew.url({ cid: account })} + class="m-4 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" + > + <i18n.Translate>New AML form</i18n.Translate> + </a> + + <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 for account{" "} + <span title={account}>{account.substring(0, 16)}...</span> + </i18n.Translate> + </h1> + </header> + <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: TalerExchangeApi.AmlState }): VNode { + switch (state) { + case TalerExchangeApi.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 TalerExchangeApi.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 TalerExchangeApi.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 + 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); + }} + > + <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> + ); + } + 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> + </div> + </div> + </li> + ); + })} + </ul> + </div> + ); +} + +export type Justification<T = Record<string, unknown>> = { + // form values + value: T; +} & Omit<Omit<FormMetadata, "icon">, "config">; + +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"; + +function parseJustification( + s: string, + listOfAllKnownForms: FormMetadata[], +): + | OperationOk<{ + justification: Justification; + metadata: FormMetadata; + }> + | OperationFail<ParseJustificationFail> { + try { + const justification = JSON.parse(s); + const info = codecForSimpleFormMetadata().decode(justification); + if (!info.id) { + return { + type: "fail", + case: "id-not-found", + detail: {} as TalerErrorDetail, + }; + } + if (!info.version) { + return { + type: "fail", + case: "version-not-found", + detail: {} as TalerErrorDetail, + }; + } + 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 TalerErrorDetail, + }; + } + return { + type: "ok", + body: { + justification, + metadata: found, + }, + }; + } catch (e) { + return { + type: "fail", + case: "not-json", + detail: {} as TalerErrorDetail, + }; + } +} |