almost first document

This commit is contained in:
Sebastian 2023-05-10 00:53:37 -03:00
parent 1c39b2befa
commit cb53546035
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
15 changed files with 1788 additions and 8 deletions

View File

@ -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 <Dashboard />;
return (
<TranslationProvider source={{}}>
<Dashboard>
<Form />
</Dashboard>
</TranslationProvider>
);
}

View File

@ -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() {
</div>
<main class="py-10">
<div class="px-4 sm:px-6 lg:px-8">{/* Your content */}</div>
<div class="px-4 sm:px-6 lg:px-8">{children}</div>
</main>
</div>
</div>

View File

@ -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<Person>;
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<Form902_1e.Form>({
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 (
<FormProvider
state={formState}
computeFormState={(v) => {
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",
},
},
};
}}
>
<div class="space-y-10 divide-y -mt-5 divide-gray-900/10">
{firstForm.map((section) => {
return (
<div 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 sm:rounded-xl md:col-span-2">
<div class="px-4 py-6 sm:p-8">
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<RenderAllFieldsByUiConfig fields={section.fields} />
</div>
</div>
</div>
</div>
);
})}
</div>
</FormProvider>
);
}

View File

@ -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<T> {
initialValue: Partial<T>;
value: Partial<T>;
onUpdate: StateUpdater<T>;
computeFormState?: (v: T) => FormState<T>;
}
//@ts-ignore
export const FormContext = createContext<FormType<any>>({});
type FormState<T> = {
[field in keyof T]?: T[field] extends AbsoluteTime
? Partial<InputFieldState>
: T[field] extends object
? FormState<T[field]>
: Partial<InputFieldState>;
};
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<T>({
children,
state,
computeFormState,
}: {
state: [Partial<T>, StateUpdater<T>];
computeFormState?: (v: T) => FormState<T>;
children: ComponentChildren;
}): VNode {
const [value, onUpdate] = state;
const initialValue = useMemo(() => value, []);
const contextValue = useMemo(
() => ({ initialValue, value, onUpdate, computeFormState }),
[value, onUpdate, computeFormState],
);
return (
<FormContext.Provider value={contextValue}>
<form>{children}</form>
</FormContext.Provider>
);
}

View File

@ -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<Array<{}>>,
): VNode {
const { fields, labelField, name, label, required, tooltip } = props;
const { value, onChange } = useField<{ [s: string]: Array<any> }>(name);
const list = value ?? [];
const [selectedIndex, setSelected] = useState<number | undefined>(undefined);
const selected =
selectedIndex === undefined ? undefined : list[selectedIndex];
const formState = useState(selected ?? {});
useEffect(() => {
const [, update] = formState;
update(selected);
}, [selected]);
return (
<div class="sm:col-span-6">
<LabelWithTooltipMaybeRequired
label={label}
required={required}
tooltip={tooltip}
/>
<div class="flex mb-4 items-center pt-3">
<div class="flex-auto">
{selectedIndex !== undefined && (
<button
type="button"
onClick={() => {
setSelected(undefined);
}}
class="block rounded-md bg-white px-3 py-2 text-center text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
Cancel
</button>
)}
</div>
<div class="flex-none">
{selectedIndex === undefined && (
<button
type="button"
onClick={() => {
setSelected(list.length);
}}
class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-indigo-500 "
>
Add
</button>
)}
</div>
</div>
<div class="-space-y-px rounded-md bg-white ">
{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 (
<label class={clazz}>
<Fragment>
<input
type="radio"
name="privacy-setting"
checked={isSelected}
disabled={disabled}
onClick={() => setSelected(idx)}
class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200 focus:ring-indigo-600"
aria-labelledby="privacy-setting-0-label"
aria-describedby="privacy-setting-0-description"
/>
<span class="ml-3 flex flex-col">
<span
id="privacy-setting-0-label"
disabled
class="block text-sm font-medium"
>
{v[labelField]}
</span>
{/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> */}
{/* <span
id="privacy-setting-0-description"
class="block text-sm"
>
This project would be available to anyone who has the link
</span> */}
</span>
</Fragment>
</label>
);
})}
</div>
{selectedIndex !== undefined && (
<FormProvider state={formState}>
<div class="px-4 py-6">
<div class="grid grid-cols-1 gap-y-8 ">
<RenderAllFieldsByUiConfig fields={fields} />
</div>
</div>
</FormProvider>
)}
{selectedIndex !== undefined && (
<div class="flex items-center pt-3">
<div class="flex-auto">
{selected !== undefined && (
<button
type="button"
onClick={() => {
const newValue = [...list];
newValue.splice(selectedIndex, 1);
onChange(newValue);
setSelected(undefined);
}}
class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
>
Remove
</button>
)}
</div>
<div class="flex-none">
<button
type="button"
onClick={() => {
const newValue = [...list];
const [confirmed] = formState;
newValue.splice(selectedIndex, 1, confirmed);
onChange(newValue);
setSelected(undefined);
}}
class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-indigo-500 "
>
Confirm
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -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<string>,
): VNode {
const {
choices,
name,
label,
tooltip,
placeholder,
required,
before,
after,
converter,
} = props;
const { value, onChange, state, isDirty } = useField(name);
if (state.hidden) {
return <Fragment />;
}
return (
<div class="sm:col-span-4">
<LabelWithTooltipMaybeRequired
label={label}
required={required}
tooltip={tooltip}
/>
<fieldset class="mt-2">
{value !== undefined && !required && (
<div class="flex mb-2 items-center ">
<div class="flex-auto">
<button
type="button"
onClick={() => {
onChange(undefined!);
}}
class="block rounded-md bg-white px-3 py-2 text-center text-sm font-semibold shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
)}
<div class="space-y-4">
{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 (
<label class={clazz}>
<input
type="radio"
name="server-size"
value={choice.value}
onClick={(e) => {
onChange(choice.value);
}}
class="sr-only"
aria-labelledby="server-size-0-label"
aria-describedby="server-size-0-description-0 server-size-0-description-1"
/>
<span class="flex items-center">
<span class="flex flex-col text-sm">
<span
id="server-size-0-label"
class="font-medium text-gray-900"
>
{choice.label}
</span>
{choice.description !== undefined && (
<span
id="server-size-0-description-0"
class="text-gray-500"
>
<span class="block sm:inline">
{choice.description}
</span>
</span>
)}
</span>
</span>
</label>
);
})}
</div>
</fieldset>
</div>
);
}

View File

@ -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<AbsoluteTime>,
): VNode {
const pattern = props.pattern ?? "dd/MM/yyyy";
return (
<InputLine<AbsoluteTime>
type="text"
after={{
type: "icon",
icon: <CalendarIcon class="h-6 w-6" />,
}}
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}
/>
);
}

View File

@ -0,0 +1,19 @@
import { VNode, h } from "preact";
import { InputLine, UIFormProps } from "./InputLine.js";
export function InputInteger(props: UIFormProps<number>): VNode {
return (
<InputLine
type="number"
converter={{
fromStringUI: (v) => {
return !v ? 0 : Number.parseInt(v, 10);
},
toStringUI: (v?: number) => {
return v === undefined ? "" : String(v);
},
}}
{...props}
/>
);
}

View File

@ -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<T> {
toStringUI: (v?: T) => string;
fromStringUI: (v?: string) => T;
}
export interface UIFormProps<T> {
name: string;
label: TranslatedString;
placeholder?: TranslatedString;
tooltip?: TranslatedString;
help?: TranslatedString;
before?: Addon;
after?: Addon;
required?: boolean;
converter?: StringConverter<T>;
}
export type FormErrors<T> = {
[P in keyof T]?: string | FormErrors<T[P]>;
};
//@ts-ignore
const TooltipIcon = (
<svg
class="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
);
export function LabelWithTooltipMaybeRequired({
label,
required,
tooltip,
}: {
label: TranslatedString;
required?: boolean;
tooltip?: TranslatedString;
}): VNode {
const Label = (
<Fragment>
<div class="flex justify-between">
<label
htmlFor="email"
class="block text-sm font-medium leading-6 text-gray-900"
>
{label}
</label>
</div>
</Fragment>
);
const WithTooltip = tooltip ? (
<div class="relative flex flex-grow items-stretch focus-within:z-10">
{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">
<span class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg">
{tooltip}
</span>
<div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div>
</div>
</span>
</div>
) : (
Label
);
if (required) {
return (
<div class="flex justify-between">
{WithTooltip}
<span class="text-sm leading-6 text-red-600">*</span>
</div>
);
}
return WithTooltip;
}
function InputWrapper<T>({
children,
label,
tooltip,
before,
after,
help,
error,
required,
}: { error?: string; children: ComponentChildren } & UIFormProps<T>): VNode {
return (
<div class="sm:col-span-6">
<LabelWithTooltipMaybeRequired
label={label}
required={required}
tooltip={tooltip}
/>
<div class="relative mt-2 flex rounded-md shadow-sm">
{before &&
(before.type === "text" ? (
<span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
{before.text}
</span>
) : before.type === "icon" ? (
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{before.icon}
</div>
) : before.type === "button" ? (
<button
type="button"
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"
>
{before.children}
</button>
) : undefined)}
{children}
{after &&
(after.type === "text" ? (
<span class="inline-flex items-center rounded-r-md border border-l-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
{after.text}
</span>
) : after.type === "icon" ? (
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
{after.icon}
</div>
) : after.type === "button" ? (
<button
type="button"
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"
>
{after.children}
</button>
) : undefined)}
</div>
{error && (
<p class="mt-2 text-sm text-red-600" id="email-error">
{error}
</p>
)}
{help && (
<p class="mt-2 text-sm text-gray-500" id="email-description">
{help}
</p>
)}
</div>
);
}
function defaultToString(v: unknown) {
return v === undefined ? "" : typeof v !== "object" ? String(v) : "";
}
function defaultFromString(v: string) {
return v;
}
export function InputLine<T>(props: { type: string } & UIFormProps<T>): VNode {
const { name, placeholder, before, after, converter, type } = props;
const { value, onChange, state, isDirty } = useField(name);
if (state.hidden) return <Fragment />;
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 (
<InputWrapper<T> {...props} error={showError ? state.error : undefined}>
<textarea
rows={4}
name={String(name)}
onChange={(e) => {
onChange(fromString(e.currentTarget.value));
}}
placeholder={placeholder ? placeholder : undefined}
value={toString(value)}
disabled={state.disabled}
aria-invalid={showError}
// aria-describedby="email-error"
class={clazz}
/>
</InputWrapper>
);
}
return (
<InputWrapper<T> {...props} error={showError ? state.error : undefined}>
<input
name={String(name)}
type={type}
onChange={(e) => {
onChange(fromString(e.currentTarget.value));
}}
placeholder={placeholder ? placeholder : undefined}
value={toString(value)}
disabled={state.disabled}
aria-invalid={showError}
// aria-describedby="email-error"
class={clazz}
/>
</InputWrapper>
);
}

View File

@ -0,0 +1,144 @@
import { Fragment, VNode, h } from "preact";
import { Choice } from "./InputChoice.js";
import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
import { useField } from "./useField.js";
import { useState } from "preact/hooks";
export function InputSelectMultiple(
props: {
choices: Choice[];
} & UIFormProps<Array<string>>,
): VNode {
const { name, label, choices, placeholder, tooltip, required } = props;
const { value, onChange } = useField<{ [s: string]: Array<string> }>(name);
const [filter, setFilter] = useState<string | undefined>(undefined);
const regex = new RegExp(`.*${filter}.*`, "i");
const choiceMap = choices.reduce((prev, curr) => {
return { ...prev, [curr.value]: curr.label };
}, {} as Record<string, string>);
const list = value ?? [];
const filteredChoices =
filter === undefined
? undefined
: choices.filter((v) => {
return regex.test(v.label);
});
return (
<div class="sm:col-span-4">
<LabelWithTooltipMaybeRequired
label={label}
required={required}
tooltip={tooltip}
/>
{list.map((v, idx) => {
return (
<span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600">
{choiceMap[v]}
<button
type="button"
onClick={() => {
const newValue = [...list];
newValue.splice(idx, 1);
onChange(newValue);
setFilter(undefined);
}}
class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20"
>
<span class="sr-only">Remove</span>
<svg
viewBox="0 0 14 14"
class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75"
>
<path d="M4 4l6 6m0-6l-6 6" />
</svg>
<span class="absolute -inset-1"></span>
</button>
</span>
);
})}
<div class="relative mt-2">
<input
id="combobox"
type="text"
value={filter ?? ""}
onChange={(e) => {
setFilter(e.currentTarget.value);
}}
placeholder={placeholder}
class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
role="combobox"
aria-controls="options"
aria-expanded="false"
/>
<button
type="button"
onClick={() => {
setFilter(filter === undefined ? "" : undefined);
}}
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
>
<svg
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>
{filteredChoices !== undefined && (
<ul
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
id="options"
role="listbox"
>
{filteredChoices.map((v, idx) => {
let clazz =
"relative flex border p-4 focus:outline-none disabled:text-grey";
return (
<li
class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
id="option-0"
role="option"
onClick={() => {
const newValue = [...list];
newValue.splice(0, 0, v.value);
onChange(newValue);
setFilter(undefined);
}}
// tabindex="-1"
>
{/* <!-- Selected: "font-semibold" --> */}
<span class="block truncate">{v.label}</span>
{/* <!--
Checkmark, only display for selected option.
Active: "text-white", Not Active: "text-indigo-600"
--> */}
</li>
);
})}
{/* <!--
Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation.
Active: "text-white bg-indigo-600", Not Active: "text-gray-900"
--> */}
{/* <!-- More items... --> */}
</ul>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
import { VNode, h } from "preact";
import { InputLine, UIFormProps } from "./InputLine.js";
export function InputText(props: UIFormProps<string>): VNode {
return <InputLine type="text" {...props} />;
}

View File

@ -0,0 +1,6 @@
import { VNode, h } from "preact";
import { InputLine, UIFormProps } from "./InputLine.js";
export function InputTextArea(props: UIFormProps<string>): VNode {
return <InputLine type="text-area" {...props} />;
}

View File

@ -0,0 +1,87 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { InputText } from "./InputText.js";
import { InputDate } from "./InputDate.js";
import { InputInteger } from "./InputInteger.js";
import { h as create, Fragment, VNode } from "preact";
import { InputChoiceStacked } from "./InputChoice.js";
import { InputArray } from "./InputArray.js";
import { InputSelectMultiple } from "./InputSelectMultiple.js";
import { InputTextArea } from "./InputTextArea.js";
export type DoubleColumnForm = DoubleColumnFormSection[];
type DoubleColumnFormSection = {
title: TranslatedString;
description?: TranslatedString;
fields: UIFormField[];
};
/**
* Constrain the type with the ui props
*/
type FieldType = {
separator: {};
array: Parameters<typeof InputArray>[0];
selectMultiple: Parameters<typeof InputSelectMultiple>[0];
text: Parameters<typeof InputText>[0];
textArea: Parameters<typeof InputTextArea>[0];
choiceStacked: Parameters<typeof InputChoiceStacked>[0];
date: Parameters<typeof InputDate>[0];
integer: Parameters<typeof InputInteger>[0];
};
/**
* List all the form fields so typescript can type-check the form instance
*/
export type UIFormField =
| { type: "separator"; props: FieldType["separator"] }
| { type: "array"; props: FieldType["array"] }
| { type: "selectMultiple"; props: FieldType["selectMultiple"] }
| { type: "text"; props: FieldType["text"] }
| { type: "textArea"; props: FieldType["textArea"] }
| { type: "choiceStacked"; props: FieldType["choiceStacked"] }
| { type: "integer"; props: FieldType["integer"] }
| { type: "date"; props: FieldType["date"] };
type FieldComponentFunction<key extends keyof FieldType> = (
props: FieldType[key],
) => VNode;
type UIFormFieldMap = {
[key in keyof FieldType]: FieldComponentFunction<key>;
};
function Separator(): VNode {
return create("div", {});
}
/**
* Maps input type with component implementation
*/
const UIFormConfiguration: UIFormFieldMap = {
separator: Separator,
array: InputArray,
text: InputText,
textArea: InputTextArea,
date: InputDate,
choiceStacked: InputChoiceStacked,
integer: InputInteger,
selectMultiple: InputSelectMultiple,
};
export function RenderAllFieldsByUiConfig({
fields,
}: {
fields: UIFormField[];
}): VNode {
return create(
Fragment,
{},
fields.map((field) => {
const Component = UIFormConfiguration[
field.type
] as FieldComponentFunction<any>;
return Component(field.props);
}),
);
}

View File

@ -0,0 +1,72 @@
import { TargetedEvent, useContext, useState } from "preact/compat";
import { FormContext, InputFieldState } from "./FormProvider.js";
export interface InputFieldHandler<Type> {
value: Type;
onChange: (s: Type) => void;
state: InputFieldState;
isDirty: boolean;
}
export function useField<T>(name: keyof T): InputFieldHandler<T[keyof T]> {
const {
initialValue,
value: formValue,
computeFormState,
onUpdate,
} = useContext(FormContext);
type P = typeof name;
type V = T[P];
const [isDirty, setDirty] = useState(false);
const formState = computeFormState ? computeFormState(formValue) : {};
const fieldValue = readField(formValue, String(name)) as V;
const fieldState = readField<Partial<InputFieldState>>(
formState,
String(name),
);
//default state
const state: InputFieldState = {
disabled: fieldState?.disabled ?? false,
readonly: fieldState?.readonly ?? false,
hidden: fieldState?.hidden ?? false,
error: fieldState?.error,
};
function onChange(value: V): void {
setDirty(true);
return onUpdate((prev: any) => {
return setValueDeeper(prev, String(name).split("."), value);
});
}
return {
value: fieldValue,
onChange,
isDirty,
state,
};
}
/**
* read the field of an object an support accessing it using '.'
*
* @param object
* @param name
* @returns
*/
function readField<T>(object: any, name: string): T | undefined {
return name
.split(".")
.reduce((prev, current) => prev && prev[current], object);
}
function setValueDeeper(object: any, names: string[], value: any): any {
if (names.length === 0) return value;
const [head, ...rest] = names;
if (object === undefined) {
return { [head]: setValueDeeper({}, rest, value) };
}
return { ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) };
}

View File

@ -29,10 +29,8 @@
href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
/>
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<title>Demobank</title>
<!-- Optional customization script. -->
<script src="demobank-ui-settings.js"></script>
<!-- Entry point for the demobank SPA. -->
<title>Exchange Backoffice</title>
<!-- Entry point for the SPA. -->
<script type="module" src="index.js"></script>
<link rel="stylesheet" href="index.css" />
</head>