From cb535460350bd510dd4b2b7d6bc3c6ec5f5bcdf1 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 10 May 2023 00:53:37 -0300 Subject: [PATCH] almost first document --- packages/exchange-backoffice-ui/src/App.tsx | 10 +- .../exchange-backoffice-ui/src/Dashborad.tsx | 10 +- packages/exchange-backoffice-ui/src/Form.tsx | 788 ++++++++++++++++++ .../src/forms/FormProvider.tsx | 54 ++ .../src/forms/InputArray.tsx | 166 ++++ .../src/forms/InputChoice.tsx | 119 +++ .../src/forms/InputDate.tsx | 36 + .../src/forms/InputInteger.tsx | 19 + .../src/forms/InputLine.tsx | 273 ++++++ .../src/forms/InputSelectMultiple.tsx | 144 ++++ .../src/forms/InputText.tsx | 6 + .../src/forms/InputTextArea.tsx | 6 + .../exchange-backoffice-ui/src/forms/forms.ts | 87 ++ .../src/forms/useField.ts | 72 ++ .../exchange-backoffice-ui/src/index.html | 6 +- 15 files changed, 1788 insertions(+), 8 deletions(-) create mode 100644 packages/exchange-backoffice-ui/src/Form.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/FormProvider.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputArray.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputChoice.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputDate.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputInteger.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputLine.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputSelectMultiple.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputText.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputTextArea.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/forms.ts create mode 100644 packages/exchange-backoffice-ui/src/forms/useField.ts diff --git a/packages/exchange-backoffice-ui/src/App.tsx b/packages/exchange-backoffice-ui/src/App.tsx index 04771149f..65b58a344 100644 --- a/packages/exchange-backoffice-ui/src/App.tsx +++ b/packages/exchange-backoffice-ui/src/App.tsx @@ -2,7 +2,15 @@ import { h, VNode } from "preact"; import { HeroSections } from "./HeroSections.js"; import "./scss/main.css"; import { Dashboard } from "./Dashborad.js"; +import { Form } from "./Form.js"; +import { TranslationProvider } from "@gnu-taler/web-util/browser"; export function App(): VNode { - return ; + return ( + + +
+ + + ); } diff --git a/packages/exchange-backoffice-ui/src/Dashborad.tsx b/packages/exchange-backoffice-ui/src/Dashborad.tsx index 8e137d69d..a540caa8d 100644 --- a/packages/exchange-backoffice-ui/src/Dashborad.tsx +++ b/packages/exchange-backoffice-ui/src/Dashborad.tsx @@ -1,4 +1,4 @@ -import { Fragment, h } from "preact"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; import { Dialog, Menu, Transition } from "@headlessui/react"; import { Bars3Icon, @@ -40,7 +40,11 @@ function classNames(...classes: string[]) { return classes.filter(Boolean).join(" "); } -export function Dashboard() { +export function Dashboard({ + children, +}: { + children: ComponentChildren; +}): VNode { const [sidebarOpen, setSidebarOpen] = useState(false); return ( @@ -364,7 +368,7 @@ export function Dashboard() {
-
{/* Your content */}
+
{children}
diff --git a/packages/exchange-backoffice-ui/src/Form.tsx b/packages/exchange-backoffice-ui/src/Form.tsx new file mode 100644 index 000000000..53458824d --- /dev/null +++ b/packages/exchange-backoffice-ui/src/Form.tsx @@ -0,0 +1,788 @@ +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h } from "preact"; +import { useState } from "preact/hooks"; +import { FormProvider } from "./forms/FormProvider.js"; +import { DoubleColumnForm, RenderAllFieldsByUiConfig } from "./forms/forms.js"; +import { CircleStackIcon } from "@heroicons/react/24/outline"; + +namespace Form902_1e { + interface LegalEntityCustomer { + companyName: string; + domicile: string; + contactPerson: string; + telephone: string; + email: string; + document: string; + } + interface NaturalCustomer { + fullName: string; + address: string; + telephone: string; + email: string; + dateOfBirth: AbsoluteTime; + nationality: string; + document: string; + companyName: string; + office: string; + companyDocument: string; + } + + interface Person { + fullName: string; + address: string; + dateOfBirth: AbsoluteTime; + nationality: string; + typeOfAuthorization: string; + document: string; + powerOfAttorneyArrangements: string; + } + + interface Acceptance { + when: AbsoluteTime; + acceptedBy: "face-to-face" | "authenticated-copy"; + typeOfCorrespondence: string; + language: string[]; + furtherInformation: string; + thirdPartyFullName: string; + thirdPartyAddress: string; + } + + interface Filler { + fullName: string; + when: AbsoluteTime; + } + + interface BeneficialOwner { + establishment: + | "natural-person" + | "foundation" + | "trust" + | "insurance-wrapper" + | "other"; + } + + interface CashTransactions { + typeOfBusiness: "money-exchange" | "money-and-asset-transfer" | "other"; + otherTypeOfBusiness: string; + purpose: string; + } + + // interface Enclosures { + // costumerIdentificationDocuments: string; + // documentOfPersons: string; + + // } + + export interface Form { + filler: Filler; + customerType: "natural" | "legal"; + naturalCustomer: NaturalCustomer; + legalCustomer: LegalEntityCustomer; + businessEstablisher: Array; + acceptance: Acceptance; + beneficialOwner: BeneficialOwner; + embargoEvaluation: string; + cashTransactions: CashTransactions; + // enclosures: Enclosures; + } +} + +const languageList = [ + { + label: "Mandarin Chinese" as TranslatedString, + value: "cmn", + }, + { + label: "Spanish" as TranslatedString, + value: "spa", + }, + { + label: "English" as TranslatedString, + value: "eng", + }, + { + label: "Hindi" as TranslatedString, + value: "hin", + }, + { + label: "Portuguese" as TranslatedString, + value: "por", + }, + { + label: "Bengali" as TranslatedString, + value: "ben", + }, + { + label: "Russian" as TranslatedString, + value: "rus", + }, + { + label: "Japanese" as TranslatedString, + value: "jpn", + }, + { + label: "Yue" as TranslatedString, + value: "yue", + }, + { + label: "Vietnamese" as TranslatedString, + value: "vie", + }, + { + label: "Turkish" as TranslatedString, + value: "tur", + }, + { + label: "Wu" as TranslatedString, + value: "wuu", + }, + { + label: "Marathi" as TranslatedString, + value: "mar", + }, + { + label: "Telugu" as TranslatedString, + value: "ten", + }, + { + label: "Korean" as TranslatedString, + value: "kor", + }, + { + label: "French" as TranslatedString, + value: "fra", + }, + { + label: "Tamil" as TranslatedString, + value: "tam", + }, + { + label: "Egyptian Arabic" as TranslatedString, + value: "arz", + }, + { + label: "Standard German" as TranslatedString, + value: "deu", + }, + { + label: "Urdu" as TranslatedString, + value: "urd", + }, + { + label: "Javanese" as TranslatedString, + value: "jav", + }, + { + label: "Punjabi" as TranslatedString, + value: "pan", + }, + { + label: "Italian" as TranslatedString, + value: "ita", + }, + { + label: "Gujarati" as TranslatedString, + value: "guj", + }, + { + label: "Iranian Persian" as TranslatedString, + value: "pes", + }, + { + label: "Bhojpuri" as TranslatedString, + value: "bho", + }, + { + label: "Hausa" as TranslatedString, + value: "hau", + }, +]; + +const firstForm: DoubleColumnForm = [ + { + 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: "filler.fullName", + label: "Full name" as TranslatedString, + }, + }, + { + type: "date", + props: { + name: "filler.when", + pattern: "dd/MM/yyyy", + label: "Date" 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, + fields: [ + { + type: "choiceStacked", + props: { + name: "customerType", + label: "Type of customer" as TranslatedString, + required: true, + choices: [ + { + label: "Natural person" as TranslatedString, + value: "natural", + }, + { + label: "Legal entity" as TranslatedString, + value: "legal", + }, + ], + }, + }, + { + type: "text", + props: { + name: "naturalCustomer.fullName", + label: "Full name" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "naturalCustomer.address", + label: "Residential address" as TranslatedString, + required: true, + }, + }, + { + type: "integer", + props: { + name: "naturalCustomer.telephone", + label: "Telephone" as TranslatedString, + }, + }, + { + type: "text", + props: { + name: "naturalCustomer.email", + label: "E-mail" as TranslatedString, + }, + }, + { + type: "text", + props: { + name: "naturalCustomer.dateOfBirth", + label: "Date of birth" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "naturalCustomer.nationality", + label: "Nationality" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "naturalCustomer.document", + label: "Identification document" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "naturalCustomer.companyName", + label: "Company name" as TranslatedString, + }, + }, + { + type: "text", + props: { + name: "naturalCustomer.office", + label: "Registered office" as TranslatedString, + }, + }, + { + type: "text", + props: { + name: "naturalCustomer.companyDocument", + label: "Company identification document" as TranslatedString, + }, + }, + { + type: "text", + props: { + name: "legalCustomer.companyName", + label: "Company name" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "legalCustomer.domicile", + label: "Domicile" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "legalCustomer.contactPerson", + label: "Contact person" as TranslatedString, + }, + }, + { + type: "text", + props: { + name: "legalCustomer.telephone", + label: "Telephone" as TranslatedString, + }, + }, + { + type: "text", + props: { + name: "legalCustomer.email", + label: "E-mail" as TranslatedString, + }, + }, + { + type: "text", + props: { + name: "legalCustomer.document", + label: "Identification document" as TranslatedString, + }, + }, + ], + }, + { + title: + "Information on the natural persons who establish the business relationship for legal entities and partnerships" as TranslatedString, + description: + "For legal entities and partnerships the identity of the natural persons who establish the business relationship must be verified." as TranslatedString, + fields: [ + { + type: "array", + props: { + name: "businessEstablisher", + label: "Persons" as TranslatedString, + required: true, + tooltip: "hola" as TranslatedString, + placeholder: "this is the placeholder" as TranslatedString, + fields: [ + { + type: "text", + props: { + name: "fullName", + label: "Full name" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "address", + label: "Residential address" as TranslatedString, + required: true, + }, + }, + { + type: "date", + props: { + name: "dateOfBirth", + label: "Date of birth" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "nationality", + label: "Nationality" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "typeOfAuthorization", + label: + "Type of authorization (signatory of representation)" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "document", + label: "Identification document" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "powerOfAttorneyArrangements", + label: "Residential address" as TranslatedString, + required: true, + }, + }, + ], + labelField: "fullName", + }, + }, + ], + }, + { + title: "Acceptance of business relationship" as TranslatedString, + fields: [ + { + type: "date", + props: { + name: "acceptance.when", + pattern: "dd/MM/yyyy", + label: "Date (conclusion of contract)" as TranslatedString, + }, + }, + { + type: "choiceStacked", + props: { + name: "acceptance.acceptedBy", + label: "Accepted by" as TranslatedString, + required: true, + choices: [ + { + label: "Face-to-face meeting with customer" as TranslatedString, + value: "face-to-face", + }, + { + label: + "Correspondence: authenticated copy of identification document obtained" as TranslatedString, + value: "correspondence-document", + }, + { + label: + "Correspondence: residential address validated" as TranslatedString, + value: "correspondence-address", + }, + ], + }, + }, + { + type: "choiceStacked", + props: { + name: "acceptance.typeOfCorrespondence", + label: "Type of correspondence" as TranslatedString, + choices: [ + { + label: "to the customer" as TranslatedString, + value: "face-to-face", + }, + { + label: "hold at bank" as TranslatedString, + value: "correspondence-document", + }, + { + label: "to a third party" as TranslatedString, + value: "correspondence-address", + }, + ], + }, + }, + { + type: "text", + props: { + name: "acceptance.thirdPartyFullName", + label: "Third party full name" as TranslatedString, + required: true, + }, + }, + { + type: "text", + props: { + name: "acceptance.thirdPartyAddress", + label: "Third party address" as TranslatedString, + required: true, + }, + }, + { + type: "selectMultiple", + props: { + name: "acceptance.language", + label: "Languages" as TranslatedString, + choices: languageList, + }, + }, + { + type: "textArea", + props: { + name: "acceptance.furtherInformation", + label: "Further information" as TranslatedString, + }, + }, + ], + }, + { + title: + "Information on the beneficial owner of the assets and/or controlling person" as TranslatedString, + description: + "Establishment of the beneficial owner of the assets and/or controlling person" as TranslatedString, + fields: [ + { + type: "choiceStacked", + props: { + name: "establishment", + label: "The customer is" as TranslatedString, + required: true, + choices: [ + { + label: + "a natural person and there are no doubts that this person is the sole beneficial owner of the assets" as TranslatedString, + value: "natural", + }, + { + label: + "a foundation (or a similar construct; incl. underlying companies)" as TranslatedString, + value: "foundation", + }, + { + label: "a trust (incl. underlying companies)" as TranslatedString, + value: "trust", + }, + { + label: + "a life insurance policy with separately managed accounts/securities accounts" as TranslatedString, + value: "insurance-wrapper", + }, + { + label: "all other cases" as TranslatedString, + value: "other", + }, + ], + }, + }, + ], + }, + { + title: + "Evaluation with regard to embargo procedures/terrorism lists on establishing the business relationship" as TranslatedString, + description: + "Verification whether the customer, beneficial owners of the assets, controlling persons, authorized representatives or other involved persons are listed on an embargo/terrorism list (date of verification/result)" as TranslatedString, + fields: [ + { + type: "textArea", + props: { + name: "embargoEvaluation", + help: "The evaluation must be made at the beginning of the business relationship and has to be repeated in the case of permanent business relationship every time the according lists are updated." as TranslatedString, + label: "Evaluation" as TranslatedString, + }, + }, + ], + }, + + { + title: + "In the case of cash transactions/occasional customers: Information on type and purpose of business relationship" as TranslatedString, + description: + "These details are only necessary for occasional customers, i.e. money exchange, money and asset transfer or other cash transactions provided that no customer profile (VQF doc. No. 902.5) is created" as TranslatedString, + fields: [ + { + type: "choiceStacked", + props: { + name: "cashTransactions.typeOfBusiness", + label: "Type of business relationship" as TranslatedString, + choices: [ + { + label: "Money exchange" as TranslatedString, + value: "money-exchange", + }, + { + label: "Money and asset transfer" as TranslatedString, + value: "money-and-asset-transfer", + }, + { + label: + "Other cash transactions. Specify below" as TranslatedString, + value: "other", + }, + ], + }, + }, + { + type: "text", + props: { + name: "cashTransactions.otherTypeOfBusiness", + required: true, + label: "Specify other cash transactions:" as TranslatedString, + }, + }, + { + type: "textArea", + props: { + name: "cashTransactions.purpose", + label: + "Purpose of the business relationship (purpose of service requested)" as TranslatedString, + }, + }, + ], + }, + { + title: "Enclosures" as TranslatedString, + fields: [ + { + type: "text", + props: { + label: "Customer identification documents" as TranslatedString, + name: "ASd", + }, + }, + ], + }, +]; + +export function Form() { + const { i18n } = useTranslationContext(); + const formState = useState({ + filler: { + fullName: "Sebastian Marchano", + when: { + t_ms: Date.now(), + }, + }, + // acceptance: { + // language: ["spa"], + // }, + // businessEstablisher: [ + // { + // document: "2121", + // fullName: "sebastian marchano", + // }, + // { + // document: "3131", + // fullName: "romina cordoba", + // }, + // ], + } as Form902_1e.Form); + + return ( + { + return { + filler: { + fullName: { + disabled: true, + }, + when: { + disabled: true, + }, + }, + acceptance: { + thirdPartyFullName: { + hidden: + v.acceptance?.typeOfCorrespondence !== "correspondence-address", + }, + thirdPartyAddress: { + hidden: + v.acceptance?.typeOfCorrespondence !== "correspondence-address", + }, + }, + cashTransactions: { + otherTypeOfBusiness: { + hidden: v.cashTransactions?.typeOfBusiness !== "other", + }, + }, + naturalCustomer: { + fullName: { + hidden: v.customerType !== "natural", + }, + address: { + hidden: v.customerType !== "natural", + }, + telephone: { + hidden: v.customerType !== "natural", + }, + email: { + hidden: v.customerType !== "natural", + }, + dateOfBirth: { + hidden: v.customerType !== "natural", + }, + nationality: { + hidden: v.customerType !== "natural", + }, + document: { + hidden: v.customerType !== "natural", + }, + companyName: { + hidden: v.customerType !== "natural", + }, + office: { + hidden: v.customerType !== "natural", + }, + companyDocument: { + hidden: v.customerType !== "natural", + }, + }, + legalCustomer: { + companyName: { + hidden: v.customerType !== "legal", + }, + contactPerson: { + hidden: v.customerType !== "legal", + }, + document: { + hidden: v.customerType !== "legal", + }, + domicile: { + hidden: v.customerType !== "legal", + }, + email: { + hidden: v.customerType !== "legal", + }, + telephone: { + hidden: v.customerType !== "legal", + }, + }, + }; + }} + > +
+ {firstForm.map((section) => { + return ( +
+
+

+ {section.title} +

+ {section.description && ( +

+ {section.description} +

+ )} +
+
+
+
+ +
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/FormProvider.tsx b/packages/exchange-backoffice-ui/src/forms/FormProvider.tsx new file mode 100644 index 000000000..c9b6783e6 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/FormProvider.tsx @@ -0,0 +1,54 @@ +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; +import { ComponentChildren, VNode, createContext, h } from "preact"; +import { StateUpdater, useMemo } from "preact/hooks"; + +export interface FormType { + initialValue: Partial; + value: Partial; + onUpdate: StateUpdater; + computeFormState?: (v: T) => FormState; +} + +//@ts-ignore +export const FormContext = createContext>({}); + +type FormState = { + [field in keyof T]?: T[field] extends AbsoluteTime + ? Partial + : T[field] extends object + ? FormState + : Partial; +}; + +export interface InputFieldState { + /* should show the error */ + error?: TranslatedString; + /* should not allow to edit */ + readonly: boolean; + /* should show as disable */ + disabled: boolean; + /* should not show */ + hidden: boolean; +} + +export function FormProvider({ + children, + state, + computeFormState, +}: { + state: [Partial, StateUpdater]; + computeFormState?: (v: T) => FormState; + children: ComponentChildren; +}): VNode { + const [value, onUpdate] = state; + const initialValue = useMemo(() => value, []); + const contextValue = useMemo( + () => ({ initialValue, value, onUpdate, computeFormState }), + [value, onUpdate, computeFormState], + ); + return ( + + {children} + + ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputArray.tsx b/packages/exchange-backoffice-ui/src/forms/InputArray.tsx new file mode 100644 index 000000000..f60ed4160 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputArray.tsx @@ -0,0 +1,166 @@ +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { FormProvider } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; +import { useField } from "./useField.js"; + +export function InputArray( + props: { + fields: UIFormField[]; + labelField: string; + } & UIFormProps>, +): VNode { + const { fields, labelField, name, label, required, tooltip } = props; + const { value, onChange } = useField<{ [s: string]: Array }>(name); + const list = value ?? []; + const [selectedIndex, setSelected] = useState(undefined); + const selected = + selectedIndex === undefined ? undefined : list[selectedIndex]; + const formState = useState(selected ?? {}); + useEffect(() => { + const [, update] = formState; + update(selected); + }, [selected]); + return ( +
+ + +
+
+ {selectedIndex !== undefined && ( + + )} +
+
+ {selectedIndex === undefined && ( + + )} +
+
+
+ {list.map((v, idx) => { + const isFirst = idx === 0; + const isLast = idx === list.length - 1; + const isSelected = selectedIndex === idx; + const disabled = selectedIndex !== undefined && !isSelected; + let clazz = + "relative flex border p-4 focus:outline-none disabled:text-grey"; + if (isFirst) { + clazz += " rounded-tl-md rounded-tr-md "; + } + if (isLast) { + clazz += " rounded-bl-md rounded-br-md "; + } + if (isSelected) { + clazz += " z-10 border-indigo-200 bg-indigo-50 "; + } else { + clazz += " border-gray-200"; + } + if (disabled) { + clazz += + " cursor-not-allowed bg-gray-50 text-gray-500 ring-gray-200 text-gray"; + } else { + clazz += " cursor-pointer"; + } + return ( + + ); + })} +
+ {selectedIndex !== undefined && ( + +
+
+ +
+
+
+ )} + {selectedIndex !== undefined && ( +
+
+ {selected !== undefined && ( + + )} +
+
+ +
+
+ )} +
+ ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputChoice.tsx b/packages/exchange-backoffice-ui/src/forms/InputChoice.tsx new file mode 100644 index 000000000..dae5ff34a --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputChoice.tsx @@ -0,0 +1,119 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { useField } from "./useField.js"; + +function classNames(...classes: string[]): string { + return classes.filter(Boolean).join(" "); +} +const memoryOptions = [ + { name: "4 GB", inStock: true }, + { name: "8 GB", inStock: true }, + { name: "16 GB", inStock: true }, + { name: "32 GB", inStock: true }, + { name: "64 GB", inStock: true }, + { name: "128 GB", inStock: false }, +]; + +export interface Choice { + label: TranslatedString; + description?: TranslatedString; + value: string; +} + +export function InputChoiceStacked( + props: { + choices: Choice[]; + } & UIFormProps, +): VNode { + const { + choices, + name, + label, + tooltip, + placeholder, + required, + before, + after, + converter, + } = props; + const { value, onChange, state, isDirty } = useField(name); + if (state.hidden) { + return ; + } + + return ( +
+ +
+ {value !== undefined && !required && ( +
+
+ +
+
+ )} + +
+ {choices.map((choice) => { + 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"; + if (choice.value === value) { + clazz += + " border-transparent border-indigo-600 ring-2 ring-indigo-600"; + } else { + clazz += " border-gray-300"; + } + return ( + + ); + })} +
+
+
+ ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputDate.tsx b/packages/exchange-backoffice-ui/src/forms/InputDate.tsx new file mode 100644 index 000000000..c9e1421f8 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputDate.tsx @@ -0,0 +1,36 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { InputLine, UIFormProps } from "./InputLine.js"; +import { CalendarIcon } from "@heroicons/react/24/outline"; +import { VNode, h } from "preact"; +import { format, parse } from "date-fns"; + +export function InputDate( + props: { pattern?: string } & UIFormProps, +): VNode { + const pattern = props.pattern ?? "dd/MM/yyyy"; + return ( + + type="text" + after={{ + type: "icon", + icon: , + }} + converter={{ + fromStringUI: (v) => { + if (!v) return { t_ms: "never" }; + console.log("from", v); + const t_ms = parse(v, pattern, Date.now()).getTime(); + return { t_ms }; + }, + toStringUI: (v) => { + return v === undefined + ? "" + : v.t_ms === "never" + ? "never" + : format(v.t_ms, pattern); + }, + }} + {...props} + /> + ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputInteger.tsx b/packages/exchange-backoffice-ui/src/forms/InputInteger.tsx new file mode 100644 index 000000000..49e6973fc --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputInteger.tsx @@ -0,0 +1,19 @@ +import { VNode, h } from "preact"; +import { InputLine, UIFormProps } from "./InputLine.js"; + +export function InputInteger(props: UIFormProps): VNode { + return ( + { + return !v ? 0 : Number.parseInt(v, 10); + }, + toStringUI: (v?: number) => { + return v === undefined ? "" : String(v); + }, + }} + {...props} + /> + ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputLine.tsx b/packages/exchange-backoffice-ui/src/forms/InputLine.tsx new file mode 100644 index 000000000..0870e885a --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputLine.tsx @@ -0,0 +1,273 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useField } from "./useField.js"; + +interface IconAddon { + type: "icon"; + icon: VNode; +} +interface ButtonAddon { + type: "button"; + onClick: () => void; + children: ComponentChildren; +} +interface TextAddon { + type: "text"; + text: TranslatedString; +} +type Addon = IconAddon | ButtonAddon | TextAddon; + +interface StringConverter { + toStringUI: (v?: T) => string; + fromStringUI: (v?: string) => T; +} + +export interface UIFormProps { + name: string; + label: TranslatedString; + placeholder?: TranslatedString; + tooltip?: TranslatedString; + help?: TranslatedString; + before?: Addon; + after?: Addon; + required?: boolean; + converter?: StringConverter; +} + +export type FormErrors = { + [P in keyof T]?: string | FormErrors; +}; + +//@ts-ignore +const TooltipIcon = ( + + + +); + +export function LabelWithTooltipMaybeRequired({ + label, + required, + tooltip, +}: { + label: TranslatedString; + required?: boolean; + tooltip?: TranslatedString; +}): VNode { + const Label = ( + +
+ +
+
+ ); + const WithTooltip = tooltip ? ( +
+ {Label} + + {TooltipIcon} + + +
+ ) : ( + Label + ); + if (required) { + return ( +
+ {WithTooltip} + * +
+ ); + } + return WithTooltip; +} + +function InputWrapper({ + children, + label, + tooltip, + before, + after, + help, + error, + required, +}: { error?: string; children: ComponentChildren } & UIFormProps): VNode { + return ( +
+ +
+ {before && + (before.type === "text" ? ( + + {before.text} + + ) : before.type === "icon" ? ( +
+ {before.icon} +
+ ) : before.type === "button" ? ( + + ) : undefined)} + + {children} + + {after && + (after.type === "text" ? ( + + {after.text} + + ) : after.type === "icon" ? ( +
+ {after.icon} +
+ ) : after.type === "button" ? ( + + ) : undefined)} +
+ {error && ( +

+ {error} +

+ )} + {help && ( +

+ {help} +

+ )} +
+ ); +} + +function defaultToString(v: unknown) { + return v === undefined ? "" : typeof v !== "object" ? String(v) : ""; +} +function defaultFromString(v: string) { + return v; +} + +export function InputLine(props: { type: string } & UIFormProps): VNode { + const { name, placeholder, before, after, converter, type } = props; + const { value, onChange, state, isDirty } = useField(name); + + if (state.hidden) return ; + + let clazz = + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200"; + if (before) { + switch (before.type) { + case "icon": { + clazz += " pl-10"; + break; + } + case "button": { + clazz += " rounded-none rounded-r-md "; + break; + } + case "text": { + clazz += " min-w-0 flex-1 rounded-r-md rounded-none "; + break; + } + } + } + if (after) { + switch (after.type) { + case "icon": { + clazz += " pr-10"; + break; + } + case "button": { + clazz += " rounded-none rounded-l-md"; + break; + } + case "text": { + clazz += " min-w-0 flex-1 rounded-l-md rounded-none "; + break; + } + } + } + const showError = isDirty && state.error; + if (showError) { + clazz += + " text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500"; + } else { + 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 ( + {...props} error={showError ? state.error : undefined}> +