diff options
Diffstat (limited to 'packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx')
-rw-r--r-- | packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx | 284 |
1 files changed, 284 insertions, 0 deletions
diff --git a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx new file mode 100644 index 000000000..7801625d0 --- /dev/null +++ b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx @@ -0,0 +1,284 @@ +/* + 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, + HttpStatusCode, + TalerExchangeApi, + TalerProtocolTimestamp, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Button, + FormMetadata, + InternationalizationAPI, + LocalNotificationBanner, + RenderAllFieldsByUiConfig, + UIHandlerId, + convertUiField, + getConverterById, + useExchangeApiContext, + useLocalNotificationHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { privatePages } from "../Routing.js"; +import { useUiFormsContext } from "../context/ui-forms.js"; +import { preloadedForms } from "../forms/index.js"; +import { + FormErrors, + getRequiredFields, + getShapeFromFields, + useFormState, + validateRequiredFields, +} from "../hooks/form.js"; +import { useOfficer } from "../hooks/officer.js"; +import { Justification } from "./CaseDetails.js"; +import { undefinedIfEmpty } from "./CreateAccount.js"; +import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; + +function searchForm( + i18n: InternationalizationAPI, + forms: FormMetadata[], + formId: string, +): FormMetadata | undefined { + { + const found = forms.find((v) => v.id === formId); + if (found) return found; + } + { + const pf = preloadedForms(i18n); + const found = pf.find((v) => v.id === formId); + if (found) return found; + } + return undefined; +} + +type FormType = { + when: AbsoluteTime; + state: TalerExchangeApi.AmlState; + threshold: AmountJson; + comment: string; +}; + +export function CaseUpdate({ + account, + type: formId, +}: { + account: string; + type: string; +}): VNode { + const { i18n } = useTranslationContext(); + const officer = useOfficer(); + const { + lib: { exchange: api }, + } = useExchangeApiContext(); + + const [notification, withErrorHandler] = useLocalNotificationHandler(); + const { config } = useExchangeApiContext(); + const { forms } = useUiFormsContext(); + const initial: FormType = { + when: AbsoluteTime.now(), + state: TalerExchangeApi.AmlState.pending, + threshold: Amounts.zeroOfCurrency(config.currency), + comment: "", + }; + + if (officer.state !== "ready") { + return <HandleAccountNotReady officer={officer} />; + } + const theForm = searchForm(i18n, forms, formId); + if (!theForm) { + return <div>form with id {formId} not found</div>; + } + + const shape: Array<UIHandlerId> = []; + const requiredFields: Array<UIHandlerId> = []; + + theForm.config.design.forEach((section) => { + Array.prototype.push.apply(shape, getShapeFromFields(section.fields)); + Array.prototype.push.apply( + requiredFields, + getRequiredFields(section.fields), + ); + }); + + const [form, state] = useFormState<FormType>(shape, initial, (st) => { + const partialErrors = undefinedIfEmpty<FormErrors<FormType>>({ + state: st.state === undefined ? i18n.str`required` : undefined, + threshold: !st.threshold ? i18n.str`required` : undefined, + when: !st.when ? i18n.str`required` : undefined, + }); + + const errors = undefinedIfEmpty<FormErrors<FormType> | undefined>( + validateRequiredFields(partialErrors, st, requiredFields), + ); + + if (errors === undefined) { + return { + status: "ok", + result: st as any, + errors: undefined, + }; + } + + return { + status: "fail", + result: st as any, + errors, + }; + }); + + const validatedForm = state.status !== "ok" ? undefined : state.result; + + const submitHandler = + validatedForm === undefined + ? undefined + : withErrorHandler( + () => { + const justification: Justification = { + id: theForm.id, + label: theForm.label, + version: theForm.version, + value: validatedForm, + }; + + const decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig"> = + { + justification: JSON.stringify(justification), + decision_time: TalerProtocolTimestamp.now(), + h_payto: account, + new_state: justification.value + .state as TalerExchangeApi.AmlState, + new_threshold: Amounts.stringify( + justification.value.threshold as AmountJson, + ), + kyc_requirements: undefined, + }; + + return api.addDecisionDetails(officer.account, decision); + }, + () => { + window.location.href = privatePages.cases.url({}); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Forbidden: + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials for "${officer.account}"`; + case HttpStatusCode.NotFound: + return i18n.str`Officer or account not found`; + case HttpStatusCode.Conflict: + return i18n.str`Officer disabled or more recent decision was already submitted.`; + default: + assertUnreachable(fail); + } + }, + ); + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> + {theForm.config.design.map((section, i) => { + if (!section) return <Fragment />; + return ( + <div + key={i} + class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3" + > + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + {section.title} + </h2> + {section.description && ( + <p class="mt-1 text-sm leading-6 text-gray-600"> + {section.description} + </p> + )} + </div> + <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={i} + fields={convertUiField( + i18n, + section.fields, + form, + getConverterById, + )} + /> + </div> + </div> + </div> + </div> + ); + })} + </div> + + <div class="mt-6 flex items-center justify-end gap-x-6"> + <a + href={privatePages.caseDetails.url({ cid: account })} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <Button + type="submit" + handler={submitHandler} + disabled={!submitHandler} + class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Confirm</i18n.Translate> + </Button> + </div> + </Fragment> + ); +} + +export function SelectForm({ account }: { account: string }) { + const { i18n } = useTranslationContext(); + const { forms } = useUiFormsContext(); + const pf = preloadedForms(i18n); + return ( + <div> + <pre>New form for account: {account.substring(0, 16)}...</pre> + {forms.map((form) => { + return ( + <a + key={form.id} + href={privatePages.caseUpdate.url({ cid: 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.label} + </a> + ); + })} + {pf.map((form) => { + return ( + <a + key={form.id} + href={privatePages.caseUpdate.url({ cid: 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.label} + </a> + ); + })} + </div> + ); +} |