diff options
Diffstat (limited to 'packages/auditor-backoffice-ui/src/components')
49 files changed, 6288 insertions, 0 deletions
diff --git a/packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx b/packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx new file mode 100644 index 000000000..b1fc33877 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/exception/AsyncButton.tsx @@ -0,0 +1,55 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ComponentChildren, h } from "preact"; +import { LoadingModal } from "../modal/index.js"; +import { useAsync } from "../../hooks/async.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; + +type Props = { + children: ComponentChildren; + disabled: boolean; + onClick?: () => Promise<void>; + [rest: string]: any; +}; + +export function AsyncButton({ onClick, disabled, children, ...rest }: Props) { + const { isSlow, isLoading, request, cancel } = useAsync(onClick); + const { i18n } = useTranslationContext(); + if (isSlow) { + return <LoadingModal onCancel={cancel} />; + } + if (isLoading) { + return ( + <button class="button"> + <i18n.Translate>Loading...</i18n.Translate> + </button> + ); + } + + return ( + <span {...rest}> + <button class="button is-success" onClick={request} disabled={disabled}> + {children} + </button> + </span> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/exception/QR.tsx b/packages/auditor-backoffice-ui/src/components/exception/QR.tsx new file mode 100644 index 000000000..c9340ea76 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/exception/QR.tsx @@ -0,0 +1,49 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { h, VNode } from "preact"; +import { useEffect, useRef } from "preact/hooks"; +import qrcode from "qrcode-generator"; + +export function QR({ text }: { text: string }): VNode { + const divRef = useRef<HTMLDivElement>(null); + useEffect(() => { + const qr = qrcode(0, "L"); + qr.addData(text); + qr.make(); + if (divRef.current) { + divRef.current.innerHTML = qr.createSvgTag({ + scalable: true, + }); + } + }); + + return ( + <div + style={{ + width: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + }} + > + <div + style={{ width: "50%", minWidth: 200, maxWidth: 300 }} + ref={divRef} + /> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/exception/loading.tsx b/packages/auditor-backoffice-ui/src/components/exception/loading.tsx new file mode 100644 index 000000000..a043b81eb --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/exception/loading.tsx @@ -0,0 +1,48 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from "preact"; + +export function Loading(): VNode { + return ( + <div + class="columns is-centered is-vcentered" + style={{ + height: "calc(100% - 3rem)", + position: "absolute", + width: "100%", + }} + > + <Spinner /> + </div> + ); +} + +export function Spinner(): VNode { + return ( + <div class="lds-ring"> + <div /> + <div /> + <div /> + <div /> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/FormProvider.tsx b/packages/auditor-backoffice-ui/src/components/form/FormProvider.tsx new file mode 100644 index 000000000..0d53c4d08 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/FormProvider.tsx @@ -0,0 +1,109 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useMemo } from "preact/hooks"; + +type Updater<S> = (value: (prevState: S) => S) => void; + +export interface Props<T> { + object?: Partial<T>; + errors?: FormErrors<T>; + name?: string; + valueHandler: Updater<Partial<T>> | null; + children: ComponentChildren; +} + +const noUpdater: Updater<Partial<unknown>> = () => (s: unknown) => s; + +export function FormProvider<T>({ + object = {}, + errors = {}, + name = "", + valueHandler, + children, +}: Props<T>): VNode { + const initialObject = useMemo(() => object, []); + const value = useMemo<FormType<T>>( + () => ({ + errors, + object, + initialObject, + valueHandler: valueHandler ? valueHandler : noUpdater, + name, + toStr: {}, + fromStr: {}, + }), + [errors, object, valueHandler], + ); + + return ( + <FormContext.Provider value={value}> + <form + class="field" + onSubmit={(e) => { + e.preventDefault(); + // if (valueHandler) valueHandler(object); + }} + > + {children} + </form> + </FormContext.Provider> + ); +} + +export interface FormType<T> { + object: Partial<T>; + initialObject: Partial<T>; + errors: FormErrors<T>; + toStr: FormtoStr<T>; + name: string; + fromStr: FormfromStr<T>; + valueHandler: Updater<Partial<T>>; +} + +const FormContext = createContext<FormType<unknown>>(null!); + +/** + * FIXME: + * USE MEMORY EVENTS INSTEAD OF CONTEXT + * @deprecated + */ + +export function useFormContext<T>() { + return useContext<FormType<T>>(FormContext); +} + +export type FormErrors<T> = { + [P in keyof T]?: string | FormErrors<T[P]>; +}; + +export type FormtoStr<T> = { + [P in keyof T]?: (f?: T[P]) => string; +}; + +export type FormfromStr<T> = { + [P in keyof T]?: (f: string) => T[P]; +}; + +export type FormUpdater<T> = { + [P in keyof T]?: (f: keyof T) => (v: T[P]) => void; +}; diff --git a/packages/auditor-backoffice-ui/src/components/form/Input.tsx b/packages/auditor-backoffice-ui/src/components/form/Input.tsx new file mode 100644 index 000000000..c1ddcb064 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/Input.tsx @@ -0,0 +1,116 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ComponentChildren, h, VNode } from "preact"; +import { useField, InputProps } from "./useField.js"; + +interface Props<T> extends InputProps<T> { + inputType?: "text" | "number" | "multiline" | "password"; + expand?: boolean; + toStr?: (v?: any) => string; + fromStr?: (s: string) => any; + inputExtra?: any; + side?: ComponentChildren; + children?: ComponentChildren; +} + +const defaultToString = (f?: any): string => f || ""; +const defaultFromString = (v: string): any => v as any; + +const TextInput = ({ inputType, error, ...rest }: any) => + inputType === "multiline" ? ( + <textarea + {...rest} + class={error ? "textarea is-danger" : "textarea"} + rows="3" + /> + ) : ( + <input + {...rest} + class={error ? "input is-danger" : "input"} + type={inputType} + /> + ); + +export function Input<T>({ + name, + readonly, + placeholder, + tooltip, + label, + expand, + help, + children, + inputType, + inputExtra, + side, + fromStr = defaultFromString, + toStr = defaultToString, +}: Props<keyof T>): VNode { + const { error, value, onChange, required } = useField<T>(name); + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p + class={ + expand + ? "control is-expanded has-icons-right" + : "control has-icons-right" + } + > + <TextInput + error={error} + {...inputExtra} + inputType={inputType} + placeholder={placeholder} + readonly={readonly} + disabled={readonly} + name={String(name)} + value={toStr(value)} + onChange={(e: h.JSX.TargetedEvent<HTMLInputElement>): void => + onChange(fromStr(e.currentTarget.value)) + } + /> + {help} + {children} + {required && ( + <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span> + )} + </p> + {error && <p class="help is-danger">{error}</p>} + </div> + {side} + </div> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputArray.tsx b/packages/auditor-backoffice-ui/src/components/form/InputArray.tsx new file mode 100644 index 000000000..4ed4c4b28 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputArray.tsx @@ -0,0 +1,139 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { InputProps, useField } from "./useField.js"; + +export interface Props<T> extends InputProps<T> { + isValid?: (e: any) => boolean; + addonBefore?: string; + toStr?: (v?: any) => string; + fromStr?: (s: string) => any; +} + +const defaultToString = (f?: any): string => f || ""; +const defaultFromString = (v: string): any => v as any; + +export function InputArray<T>({ + name, + readonly, + placeholder, + tooltip, + label, + help, + addonBefore, + isValid = () => true, + fromStr = defaultFromString, + toStr = defaultToString, +}: Props<keyof T>): VNode { + const { error: formError, value, onChange, required } = useField<T>(name); + const [localError, setLocalError] = useState<string | null>(null); + + const error = localError || formError; + + const array: any[] = (value ? value! : []) as any; + const [currentValue, setCurrentValue] = useState(""); + const { i18n } = useTranslationContext(); + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <div class="field has-addons"> + {addonBefore && ( + <div class="control"> + <a class="button is-static">{addonBefore}</a> + </div> + )} + <p class="control is-expanded has-icons-right"> + <input + class={error ? "input is-danger" : "input"} + type="text" + placeholder={placeholder} + readonly={readonly} + disabled={readonly} + name={String(name)} + value={currentValue} + onChange={(e): void => setCurrentValue(e.currentTarget.value)} + /> + {required && ( + <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span> + )} + </p> + <p class="control"> + <button + class="button is-info has-tooltip-left" + disabled={!currentValue} + onClick={(): void => { + const v = fromStr(currentValue); + if (!isValid(v)) { + setLocalError( + i18n.str`The value ${v} is invalid for a payment url`, + ); + return; + } + setLocalError(null); + onChange([v, ...array] as any); + setCurrentValue(""); + }} + data-tooltip={i18n.str`add element to the list`} + > + <i18n.Translate>add</i18n.Translate> + </button> + </p> + </div> + {help} + {error && <p class="help is-danger"> {error} </p>} + {array.map((v, i) => ( + <div key={i} class="tags has-addons mt-3 mb-0"> + <span + class="tag is-medium is-info mb-0" + style={{ maxWidth: "90%" }} + > + {v} + </span> + <a + class="tag is-medium is-danger is-delete mb-0" + onClick={() => { + onChange(array.filter((f) => f !== v) as any); + setCurrentValue(toStr(v)); + }} + /> + </div> + ))} + </div> + </div> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx b/packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx new file mode 100644 index 000000000..f79e16c07 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputBoolean.tsx @@ -0,0 +1,91 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, VNode } from "preact"; +import { InputProps, useField } from "./useField.js"; + +interface Props<T> extends InputProps<T> { + name: T; + readonly?: boolean; + expand?: boolean; + threeState?: boolean; + toBoolean?: (v?: any) => boolean | undefined; + fromBoolean?: (s: boolean | undefined) => any; +} + +const defaultToBoolean = (f?: any): boolean | undefined => f || ""; +const defaultFromBoolean = (v: boolean | undefined): any => v as any; + +export function InputBoolean<T>({ + name, + readonly, + placeholder, + tooltip, + label, + help, + threeState, + expand, + fromBoolean = defaultFromBoolean, + toBoolean = defaultToBoolean, +}: Props<keyof T>): VNode { + const { error, value, onChange } = useField<T>(name); + + const onCheckboxClick = (): void => { + const c = toBoolean(value); + if (c === false && threeState) return onChange(undefined as any); + return onChange(fromBoolean(!c)); + }; + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class={expand ? "control is-expanded" : "control"}> + <label class="b-checkbox checkbox"> + <input + type="checkbox" + class={toBoolean(value) === undefined ? "is-indeterminate" : ""} + checked={toBoolean(value)} + placeholder={placeholder} + readonly={readonly} + name={String(name)} + disabled={readonly} + onChange={onCheckboxClick} + /> + <span class="check" /> + </label> + {help} + </p> + {error && <p class="help is-danger">{error}</p>} + </div> + </div> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputCurrency.tsx b/packages/auditor-backoffice-ui/src/components/form/InputCurrency.tsx new file mode 100644 index 000000000..b02354d7c --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputCurrency.tsx @@ -0,0 +1,67 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ComponentChildren, h, VNode } from "preact"; +import { useConfigContext } from "../../context/config.js"; +import { Amount } from "../../declaration.js"; +import { InputWithAddon } from "./InputWithAddon.js"; +import { InputProps } from "./useField.js"; + +export interface Props<T> extends InputProps<T> { + expand?: boolean; + addonAfter?: ComponentChildren; + children?: ComponentChildren; + side?: ComponentChildren; +} + +export function InputCurrency<T>({ + name, + readonly, + label, + placeholder, + help, + tooltip, + expand, + addonAfter, + children, + side, +}: Props<keyof T>): VNode { + const config = useConfigContext(); + return ( + <InputWithAddon<T> + name={name} + readonly={readonly} + addonBefore={config.currency} + side={side} + label={label} + placeholder={placeholder} + help={help} + tooltip={tooltip} + addonAfter={addonAfter} + inputType="number" + expand={expand} + toStr={(v?: Amount) => v?.split(":")[1] || ""} + fromStr={(v: string) => (!v ? undefined : `${config.currency}:${v}`)} + inputExtra={{ min: 0 }} + > + {children} + </InputWithAddon> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputDate.tsx b/packages/auditor-backoffice-ui/src/components/form/InputDate.tsx new file mode 100644 index 000000000..a398629dc --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputDate.tsx @@ -0,0 +1,164 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { ComponentChildren, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { DatePicker } from "../picker/DatePicker.js"; +import { InputProps, useField } from "./useField.js"; +import { dateFormatForSettings, useSettings } from "../../hooks/useSettings.js"; + +export interface Props<T> extends InputProps<T> { + readonly?: boolean; + expand?: boolean; + //FIXME: create separated components InputDate and InputTimestamp + withTimestampSupport?: boolean; + side?: ComponentChildren; +} + +export function InputDate<T>({ + name, + readonly, + label, + placeholder, + help, + tooltip, + expand, + withTimestampSupport, + side, +}: Props<keyof T>): VNode { + const [opened, setOpened] = useState(false); + const { i18n } = useTranslationContext(); + const [settings] = useSettings() + + const { error, required, value, onChange } = useField<T>(name); + + let strValue = ""; + if (!value) { + strValue = withTimestampSupport ? "unknown" : ""; + } else if (value instanceof Date) { + strValue = format(value, dateFormatForSettings(settings)); + } else if (value.t_s) { + strValue = + value.t_s === "never" + ? withTimestampSupport + ? "never" + : "" + : format(new Date(value.t_s * 1000), dateFormatForSettings(settings)); + } + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <div class="field has-addons"> + <p + class={ + expand + ? "control is-expanded has-icons-right" + : "control has-icons-right" + } + > + <input + class="input" + type="text" + readonly + value={strValue} + placeholder={placeholder} + onClick={() => { + if (!readonly) setOpened(true); + }} + /> + {required && ( + <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span> + )} + {help} + </p> + <div + class="control" + onClick={() => { + if (!readonly) setOpened(true); + }} + > + <a class="button is-static"> + <span class="icon"> + <i class="mdi mdi-calendar" /> + </span> + </a> + </div> + </div> + {error && <p class="help is-danger">{error}</p>} + </div> + + {!readonly && ( + <span + data-tooltip={ + withTimestampSupport + ? i18n.str`change value to unknown date` + : i18n.str`change value to empty` + } + > + <button + class="button is-info mr-3" + onClick={() => onChange(undefined as any)} + > + <i18n.Translate>clear</i18n.Translate> + </button> + </span> + )} + {withTimestampSupport && ( + <span data-tooltip={i18n.str`change value to never`}> + <button + class="button is-info" + onClick={() => onChange({ t_s: "never" } as any)} + > + <i18n.Translate>never</i18n.Translate> + </button> + </span> + )} + {side} + </div> + <DatePicker + opened={opened} + closeFunction={() => setOpened(false)} + dateReceiver={(d) => { + if (withTimestampSupport) { + onChange({ t_s: d.getTime() / 1000 } as any); + } else { + onChange(d as any); + } + }} + /> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx b/packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx new file mode 100644 index 000000000..7aa2703a4 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputDuration.tsx @@ -0,0 +1,186 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { formatDuration, intervalToDuration } from "date-fns"; +import { ComponentChildren, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { SimpleModal } from "../modal/index.js"; +import { DurationPicker } from "../picker/DurationPicker.js"; +import { InputProps, useField } from "./useField.js"; +import { Duration } from "@gnu-taler/taler-util"; + +export interface Props<T> extends InputProps<T> { + expand?: boolean; + readonly?: boolean; + withForever?: boolean; + side?: ComponentChildren; + withoutClear?: boolean; +} + +export function InputDuration<T>({ + name, + expand, + placeholder, + tooltip, + label, + help, + readonly, + withForever, + withoutClear, + side, +}: Props<keyof T>): VNode { + const [opened, setOpened] = useState(false); + const { i18n } = useTranslationContext(); + + const { error, required, value: anyValue, onChange } = useField<T>(name); + let strValue = ""; + const value: Duration = anyValue + if (!value) { + strValue = ""; + } else if (value.d_ms === "forever") { + strValue = i18n.str`forever`; + } else { + strValue = formatDuration( + intervalToDuration({ start: 0, end: value.d_ms }), + { + locale: { + formatDistance: (name, value) => { + switch (name) { + case "xMonths": + return i18n.str`${value}M`; + case "xYears": + return i18n.str`${value}Y`; + case "xDays": + return i18n.str`${value}d`; + case "xHours": + return i18n.str`${value}h`; + case "xMinutes": + return i18n.str`${value}min`; + case "xSeconds": + return i18n.str`${value}sec`; + } + }, + localize: { + day: () => "s", + month: () => "m", + ordinalNumber: () => "th", + dayPeriod: () => "p", + quarter: () => "w", + era: () => "e", + }, + }, + }, + ); + } + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal is-flex-grow-3"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + + <div class="is-flex-grow-3"> + <div class="field-body "> + <div class="field"> + <div class="field has-addons"> + <p class={expand ? "control is-expanded " : "control "}> + <input + class="input" + type="text" + readonly + value={strValue} + placeholder={placeholder} + onClick={() => { + if (!readonly) setOpened(true); + }} + /> + {required && ( + <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span> + )} + </p> + <div + class="control" + onClick={() => { + if (!readonly) setOpened(true); + }} + > + <a class="button is-static"> + <span class="icon"> + <i class="mdi mdi-clock" /> + </span> + </a> + </div> + </div> + {error && <p class="help is-danger">{error}</p>} + </div> + {withForever && ( + <span data-tooltip={i18n.str`change value to never`}> + <button + class="button is-info mr-3" + onClick={() => onChange({ d_ms: "forever" } as any)} + > + <i18n.Translate>forever</i18n.Translate> + </button> + </span> + )} + {!readonly && !withoutClear && ( + <span data-tooltip={i18n.str`change value to empty`}> + <button + class="button is-info " + onClick={() => onChange(undefined as any)} + > + <i18n.Translate>clear</i18n.Translate> + </button> + </span> + )} + {side} + </div> + <span> + {help} + </span> + </div> + + + {opened && ( + <SimpleModal onCancel={() => setOpened(false)}> + <DurationPicker + days + hours + minutes + value={!value || value.d_ms === "forever" ? 0 : value.d_ms} + onChange={(v) => { + onChange({ d_ms: v } as any); + }} + /> + </SimpleModal> + )} + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx b/packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx new file mode 100644 index 000000000..b5e0bd52b --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputGroup.tsx @@ -0,0 +1,86 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ComponentChildren, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useGroupField } from "./useGroupField.js"; + +export interface Props<T> { + name: T; + children: ComponentChildren; + label: ComponentChildren; + tooltip?: ComponentChildren; + alternative?: ComponentChildren; + fixed?: boolean; + initialActive?: boolean; +} + +export function InputGroup<T>({ + name, + label, + children, + tooltip, + alternative, + fixed, + initialActive, +}: Props<keyof T>): VNode { + const [active, setActive] = useState(initialActive || fixed); + const group = useGroupField<T>(name); + + return ( + <div class="card"> + <header class="card-header"> + <p class="card-header-title"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + {group?.hasError && ( + <span class="icon has-text-danger" data-tooltip={tooltip}> + <i class="mdi mdi-alert" /> + </span> + )} + </p> + {!fixed && ( + <button + class="card-header-icon" + aria-label="more options" + onClick={(): void => setActive(!active)} + > + <span class="icon"> + {active ? ( + <i class="mdi mdi-arrow-up" /> + ) : ( + <i class="mdi mdi-arrow-down" /> + )} + </span> + </button> + )} + </header> + {active ? ( + <div class="card-content">{children}</div> + ) : alternative ? ( + <div class="card-content">{alternative}</div> + ) : undefined} + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputImage.tsx b/packages/auditor-backoffice-ui/src/components/form/InputImage.tsx new file mode 100644 index 000000000..b024e2c6b --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputImage.tsx @@ -0,0 +1,122 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, h, VNode } from "preact"; +import { useRef, useState } from "preact/hooks"; +import { MAX_IMAGE_SIZE as MAX_IMAGE_UPLOAD_SIZE } from "../../utils/constants.js"; +import { InputProps, useField } from "./useField.js"; + +export interface Props<T> extends InputProps<T> { + expand?: boolean; + addonAfter?: ComponentChildren; + children?: ComponentChildren; +} + +export function InputImage<T>({ + name, + readonly, + placeholder, + tooltip, + label, + help, + children, + expand, +}: Props<keyof T>): VNode { + const { error, value, onChange } = useField<T>(name); + + const image = useRef<HTMLInputElement>(null); + const { i18n } = useTranslationContext(); + const [sizeError, setSizeError] = useState(false); + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class={expand ? "control is-expanded" : "control"}> + {value && ( + <img + src={value} + style={{ width: 200, height: 200 }} + onClick={() => image.current?.click()} + /> + )} + <input + ref={image} + style={{ display: "none" }} + type="file" + name={String(name)} + placeholder={placeholder} + readonly={readonly} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) { + return onChange(undefined!); + } + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true); + return onChange(undefined!); + } + setSizeError(false); + return f[0].arrayBuffer().then((b) => { + const b64 = window.btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + return onChange(`data:${f[0].type};base64,${b64}` as any); + }); + }} + /> + {help} + {children} + </p> + {error && <p class="help is-danger">{error}</p>} + {sizeError && ( + <p class="help is-danger"> + <i18n.Translate>Image should be smaller than 1 MB</i18n.Translate> + </p> + )} + {!value && ( + <button class="button" onClick={() => image.current?.click()}> + <i18n.Translate>Add</i18n.Translate> + </button> + )} + {value && ( + <button class="button" onClick={() => onChange(undefined!)}> + <i18n.Translate>Remove</i18n.Translate> + </button> + )} + </div> + </div> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx b/packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx new file mode 100644 index 000000000..a2fc8113e --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputLocation.tsx @@ -0,0 +1,53 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { Fragment, h } from "preact"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Input } from "./Input.js"; + +export function InputLocation({ name }: { name: string }) { + const { i18n } = useTranslationContext(); + return ( + <> + <Input name={`${name}.country`} label={i18n.str`Country`} /> + <Input + name={`${name}.address_lines`} + inputType="multiline" + label={i18n.str`Address`} + toStr={(v: string[] | undefined) => (!v ? "" : v.join("\n"))} + fromStr={(v: string) => v.split("\n")} + /> + <Input + name={`${name}.building_number`} + label={i18n.str`Building number`} + /> + <Input name={`${name}.building_name`} label={i18n.str`Building name`} /> + <Input name={`${name}.street`} label={i18n.str`Street`} /> + <Input name={`${name}.post_code`} label={i18n.str`Post code`} /> + <Input name={`${name}.town_location`} label={i18n.str`Town location`} /> + <Input name={`${name}.town`} label={i18n.str`Town`} /> + <Input name={`${name}.district`} label={i18n.str`District`} /> + <Input + name={`${name}.country_subdivision`} + label={i18n.str`Country subdivision`} + /> + </> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputNumber.tsx b/packages/auditor-backoffice-ui/src/components/form/InputNumber.tsx new file mode 100644 index 000000000..3b5df1474 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputNumber.tsx @@ -0,0 +1,60 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ComponentChildren, h } from "preact"; +import { InputWithAddon } from "./InputWithAddon.js"; +import { InputProps } from "./useField.js"; + +export interface Props<T> extends InputProps<T> { + readonly?: boolean; + expand?: boolean; + side?: ComponentChildren; + children?: ComponentChildren; +} + +export function InputNumber<T>({ + name, + readonly, + placeholder, + tooltip, + label, + help, + expand, + children, + side, +}: Props<keyof T>) { + return ( + <InputWithAddon<T> + name={name} + readonly={readonly} + fromStr={(v) => (!v ? undefined : parseInt(v, 10))} + toStr={(v) => `${v}`} + inputType="number" + expand={expand} + label={label} + placeholder={placeholder} + help={help} + tooltip={tooltip} + inputExtra={{ min: 0 }} + children={children} + side={side} + /> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx new file mode 100644 index 000000000..6e88e8f2c --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputPayto.tsx @@ -0,0 +1,52 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, VNode } from "preact"; +import { InputArray } from "./InputArray.js"; +import { PAYTO_REGEX } from "../../utils/constants.js"; +import { InputProps } from "./useField.js"; + +export type Props<T> = InputProps<T>; + +const PAYTO_START_REGEX = /^payto:\/\//; + +export function InputPayto<T>({ + name, + readonly, + placeholder, + tooltip, + label, + help, +}: Props<keyof T>): VNode { + return ( + <InputArray<T> + name={name} + readonly={readonly} + addonBefore="payto://" + label={label} + placeholder={placeholder} + help={help} + tooltip={tooltip} + isValid={(v) => v && PAYTO_REGEX.test(v)} + toStr={(v?: string) => (!v ? "" : v.replace(PAYTO_START_REGEX, ""))} + fromStr={(v: string) => `payto://${v}`} + /> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx new file mode 100644 index 000000000..282e52278 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.stories.tsx @@ -0,0 +1,47 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h } from "preact"; +import * as tests from "@gnu-taler/web-util/testing"; +import { InputPaytoForm } from "./InputPaytoForm.js"; +import { FormProvider } from "./FormProvider.js"; +import { useState } from "preact/hooks"; + +export default { + title: "Components/Form/PayTo", + component: InputPaytoForm, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +export const Example = tests.createExample(() => { + const initial = { + accounts: [], + }; + const [form, updateForm] = useState<Partial<typeof initial>>(initial); + return ( + <FormProvider valueHandler={updateForm} object={form}> + <InputPaytoForm name="accounts" label="Accounts:" /> + </FormProvider> + ); +}, {}); diff --git a/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx new file mode 100644 index 000000000..32545c89a --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputPaytoForm.tsx @@ -0,0 +1,397 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { parsePaytoUri, PaytoUriGeneric, stringifyPaytoUri } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { COUNTRY_TABLE } from "../../utils/constants.js"; +import { undefinedIfEmpty } from "../../utils/table.js"; +import { FormErrors, FormProvider } from "./FormProvider.js"; +import { Input } from "./Input.js"; +import { InputGroup } from "./InputGroup.js"; +import { InputSelector } from "./InputSelector.js"; +import { InputProps, useField } from "./useField.js"; +import { useEffect, useState } from "preact/hooks"; + +export interface Props<T> extends InputProps<T> { + isValid?: (e: any) => boolean; +} + +// type Entity = PaytoUriGeneric +// https://datatracker.ietf.org/doc/html/rfc8905 +type Entity = { + // iban, bitcoin, x-taler-bank. it defined the format + target: string; + // path1 if the first field to be used + path1?: string; + // path2 if the second field to be used, optional + path2?: string; + // params of the payto uri + params: { + "receiver-name"?: string; + sender?: string; + message?: string; + amount?: string; + instruction?: string; + [name: string]: string | undefined; + }; +}; + +function isEthereumAddress(address: string) { + if (!/^(0x)?[0-9a-f]{40}$/i.test(address)) { + return false; + } else if ( + /^(0x|0X)?[0-9a-f]{40}$/.test(address) || + /^(0x|0X)?[0-9A-F]{40}$/.test(address) + ) { + return true; + } + return checkAddressChecksum(address); +} + +function checkAddressChecksum(address: string) { + //TODO implement ethereum checksum + return true; +} + +function validateBitcoin( + addr: string, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): string | undefined { + try { + const valid = /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/.test(addr); + if (valid) return undefined; + } catch (e) { + console.log(e); + } + return i18n.str`This is not a valid bitcoin address.`; +} + +function validateEthereum( + addr: string, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): string | undefined { + try { + const valid = isEthereumAddress(addr); + if (valid) return undefined; + } catch (e) { + console.log(e); + } + return i18n.str`This is not a valid Ethereum address.`; +} + +/** + * An IBAN is validated by converting it into an integer and performing a + * basic mod-97 operation (as described in ISO 7064) on it. + * If the IBAN is valid, the remainder equals 1. + * + * The algorithm of IBAN validation is as follows: + * 1.- Check that the total IBAN length is correct as per the country. If not, the IBAN is invalid + * 2.- Move the four initial characters to the end of the string + * 3.- Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35 + * 4.- Interpret the string as a decimal integer and compute the remainder of that number on division by 97 + * + * If the remainder is 1, the check digit test is passed and the IBAN might be valid. + * + */ +function validateIBAN( + iban: string, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): string | undefined { + // Check total length + if (iban.length < 4) + return i18n.str`IBAN numbers usually have more that 4 digits`; + if (iban.length > 34) + return i18n.str`IBAN numbers usually have less that 34 digits`; + + const A_code = "A".charCodeAt(0); + const Z_code = "Z".charCodeAt(0); + const IBAN = iban.toUpperCase(); + // check supported country + const code = IBAN.substr(0, 2); + const found = code in COUNTRY_TABLE; + if (!found) return i18n.str`IBAN country code not found`; + + // 2.- Move the four initial characters to the end of the string + const step2 = IBAN.substr(4) + iban.substr(0, 4); + const step3 = Array.from(step2) + .map((letter) => { + const code = letter.charCodeAt(0); + if (code < A_code || code > Z_code) return letter; + return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`; + }) + .join(""); + + function calculate_iban_checksum(str: string): number { + const numberStr = str.substr(0, 5); + const rest = str.substr(5); + const number = parseInt(numberStr, 10); + const result = number % 97; + if (rest.length > 0) { + return calculate_iban_checksum(`${result}${rest}`); + } + return result; + } + + const checksum = calculate_iban_checksum(step3); + if (checksum !== 1) + return i18n.str`IBAN number is not valid, checksum is wrong`; + return undefined; +} + +// const targets = ['ach', 'bic', 'iban', 'upi', 'bitcoin', 'ilp', 'void', 'x-taler-bank'] +const targets = [ + "Choose one...", + "iban", + "x-taler-bank", + "bitcoin", + "ethereum", +]; +const noTargetValue = targets[0]; +const defaultTarget: Entity = { + target: noTargetValue, + params: {}, +}; + +export function InputPaytoForm<T>({ + name, + readonly, + label, + tooltip, +}: Props<keyof T>): VNode { + const { value: initialValueStr, onChange } = useField<T>(name); + + const initialPayto = parsePaytoUri(initialValueStr ?? "") + const paths = !initialPayto ? [] : initialPayto.targetPath.split("/") + const initialPath1 = paths.length >= 1 ? paths[0] : undefined; + const initialPath2 = paths.length >= 2 ? paths[1] : undefined; + const initial: Entity = initialPayto === undefined ? defaultTarget : { + target: initialPayto.targetType, + params: initialPayto.params, + path1: initialPath1, + path2: initialPath2, + } + const [value, setValue] = useState<Partial<Entity>>(initial) + + const { i18n } = useTranslationContext(); + + const errors: FormErrors<Entity> = { + target: + value.target === noTargetValue + ? i18n.str`required` + : undefined, + path1: !value.path1 + ? i18n.str`required` + : value.target === "iban" + ? validateIBAN(value.path1, i18n) + : value.target === "bitcoin" + ? validateBitcoin(value.path1, i18n) + : value.target === "ethereum" + ? validateEthereum(value.path1, i18n) + : undefined, + path2: + value.target === "x-taler-bank" + ? !value.path2 + ? i18n.str`required` + : undefined + : undefined, + params: undefinedIfEmpty({ + "receiver-name": !value.params?.["receiver-name"] + ? i18n.str`required` + : undefined, + }), + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + const str = hasErrors || !value.target ? undefined : stringifyPaytoUri({ + targetType: value.target, + targetPath: value.path2 ? `${value.path1}/${value.path2}` : (value.path1 ?? ""), + params: value.params ?? {} as any, + isKnown: false, + }) + useEffect(() => { + onChange(str as any) + }, [str]) + + // const submit = useCallback((): void => { + // // const accounts: MerchantBackend.BankAccounts.AccountAddDetails[] = paytos; + // // const alreadyExists = + // // accounts.findIndex((x) => x.payto_uri === paytoURL) !== -1; + // // if (!alreadyExists) { + // const newValue: MerchantBackend.BankAccounts.AccountAddDetails = { + // payto_uri: paytoURL, + // }; + // if (value.auth) { + // if (value.auth.url) { + // newValue.credit_facade_url = value.auth.url; + // } + // if (value.auth.type === "none") { + // newValue.credit_facade_credentials = { + // type: "none", + // }; + // } + // if (value.auth.type === "basic") { + // newValue.credit_facade_credentials = { + // type: "basic", + // username: value.auth.username ?? "", + // password: value.auth.password ?? "", + // }; + // } + // } + // onChange(newValue as any); + // // } + // // valueHandler(defaultTarget); + // }, [value]); + + //FIXME: translating plural singular + return ( + <InputGroup name="payto" label={label} fixed tooltip={tooltip}> + <FormProvider<Entity> + name="tax" + errors={errors} + object={value} + valueHandler={setValue} + > + <InputSelector<Entity> + name="target" + label={i18n.str`Account type`} + tooltip={i18n.str`Method to use for wire transfer`} + values={targets} + readonly={readonly} + toStr={(v) => (v === noTargetValue ? i18n.str`Choose one...` : v)} + /> + + {value.target === "ach" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n.str`Routing`} + readonly={readonly} + tooltip={i18n.str`Routing number.`} + /> + <Input<Entity> + name="path2" + label={i18n.str`Account`} + readonly={readonly} + tooltip={i18n.str`Account number.`} + /> + </Fragment> + )} + {value.target === "bic" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n.str`Code`} + readonly={readonly} + tooltip={i18n.str`Business Identifier Code.`} + /> + </Fragment> + )} + {value.target === "iban" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n.str`IBAN`} + tooltip={i18n.str`International Bank Account Number.`} + readonly={readonly} + placeholder="DE1231231231" + inputExtra={{ style: { textTransform: "uppercase" } }} + /> + </Fragment> + )} + {value.target === "upi" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Account`} + tooltip={i18n.str`Unified Payment Interface.`} + /> + </Fragment> + )} + {value.target === "bitcoin" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Address`} + tooltip={i18n.str`Bitcoin protocol.`} + /> + </Fragment> + )} + {value.target === "ethereum" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Address`} + tooltip={i18n.str`Ethereum protocol.`} + /> + </Fragment> + )} + {value.target === "ilp" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Address`} + tooltip={i18n.str`Interledger protocol.`} + /> + </Fragment> + )} + {value.target === "void" && <Fragment />} + {value.target === "x-taler-bank" && ( + <Fragment> + <Input<Entity> + name="path1" + readonly={readonly} + label={i18n.str`Host`} + tooltip={i18n.str`Bank host.`} + /> + <Input<Entity> + name="path2" + readonly={readonly} + label={i18n.str`Account`} + tooltip={i18n.str`Bank account.`} + /> + </Fragment> + )} + + {/** + * Show additional fields apart from the payto + */} + {value.target !== noTargetValue && ( + <Fragment> + <Input + name="params.receiver-name" + readonly={readonly} + label={i18n.str`Owner's name`} + tooltip={i18n.str`Legal name of the person holding the account.`} + /> + </Fragment> + )} + + </FormProvider> + </InputGroup> + ); +} + diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx new file mode 100644 index 000000000..be5800d14 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputSearchOnList.tsx @@ -0,0 +1,204 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import emptyImage from "../../assets/empty.png"; +import { FormErrors, FormProvider } from "./FormProvider.js"; +import { InputWithAddon } from "./InputWithAddon.js"; +import { TranslatedString } from "@gnu-taler/taler-util"; + +type Entity = { + id: string, + description: string; + image?: string; + extra?: string; +}; + +export interface Props<T extends Entity> { + selected?: T; + onChange: (p?: T) => void; + label: TranslatedString; + list: T[]; + withImage?: boolean; +} + +interface Search { + name: string; +} + +export function InputSearchOnList<T extends Entity>({ + selected, + onChange, + label, + list, + withImage, +}: Props<T>): VNode { + const [nameForm, setNameForm] = useState<Partial<Search>>({ + name: "", + }); + + const errors: FormErrors<Search> = { + name: undefined, + }; + const { i18n } = useTranslationContext(); + + if (selected) { + return ( + <article class="media"> + {withImage && + <figure class="media-left"> + <p class="image is-128x128"> + <img src={selected.image ? selected.image : emptyImage} /> + </p> + </figure> + } + <div class="media-content"> + <div class="content"> + <p class="media-meta"> + <i18n.Translate>ID</i18n.Translate>: <b>{selected.id}</b> + </p> + <p> + <i18n.Translate>Description</i18n.Translate>:{" "} + {selected.description} + </p> + <div class="buttons is-right mt-5"> + <button + class="button is-info" + onClick={() => onChange(undefined)} + > + clear + </button> + </div> + </div> + </div> + </article> + ); + } + + return ( + <FormProvider<Search> + errors={errors} + object={nameForm} + valueHandler={setNameForm} + > + <InputWithAddon<Search> + name="name" + label={label} + tooltip={i18n.str`enter description or id`} + addonAfter={ + <span class="icon"> + <i class="mdi mdi-magnify" /> + </span> + } + > + <div> + <DropdownList + name={nameForm.name} + list={list} + onSelect={(p) => { + setNameForm({ name: "" }); + onChange(p); + }} + withImage={!!withImage} + /> + </div> + </InputWithAddon> + </FormProvider> + ); +} + +interface DropdownListProps<T extends Entity> { + name?: string; + onSelect: (p: T) => void; + list: T[]; + withImage: boolean; +} + +function DropdownList<T extends Entity>({ name, onSelect, list, withImage }: DropdownListProps<T>) { + const { i18n } = useTranslationContext(); + if (!name) { + /* FIXME + this BR is added to occupy the space that will be added when the + dropdown appears + */ + return ( + <div> + <br /> + </div> + ); + } + const filtered = list.filter( + (p) => p.id.includes(name) || p.description.includes(name), + ); + + return ( + <div class="dropdown is-active"> + <div + class="dropdown-menu" + id="dropdown-menu" + role="menu" + style={{ minWidth: "20rem" }} + > + <div class="dropdown-content"> + {!filtered.length ? ( + <div class="dropdown-item"> + <i18n.Translate> + no match found with that description or id + </i18n.Translate> + </div> + ) : ( + filtered.map((p) => ( + <div + key={p.id} + class="dropdown-item" + onClick={() => onSelect(p)} + style={{ cursor: "pointer" }} + > + <article class="media"> + {withImage && + <div class="media-left"> + <div class="image" style={{ minWidth: 64 }}> + <img + src={p.image ? p.image : emptyImage} + style={{ width: 64, height: 64 }} + /> + </div> + </div> + } + <div class="media-content"> + <div class="content"> + <p> + <strong>{p.id}</strong> {p.extra !== undefined ? <small>{p.extra}</small> : undefined} + <br /> + {p.description} + </p> + </div> + </div> + </article> + </div> + )) + )} + </div> + </div> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx new file mode 100644 index 000000000..12ce6c6aa --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputSecured.stories.tsx @@ -0,0 +1,61 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { FormProvider } from "./FormProvider.js"; +import { InputSecured } from "./InputSecured.js"; + +export default { + title: "Components/Form/InputSecured", + component: InputSecured, +}; + +type T = { auth_token: string | null }; + +export const InitialValueEmpty = (): VNode => { + const [state, setState] = useState<Partial<T>>({ auth_token: "" }); + return ( + <FormProvider<T> object={state} errors={{}} valueHandler={setState}> + Initial value: '' + <InputSecured<T> name="auth_token" label="Access token" /> + </FormProvider> + ); +}; + +export const InitialValueToken = (): VNode => { + const [state, setState] = useState<Partial<T>>({ auth_token: "token" }); + return ( + <FormProvider<T> object={state} errors={{}} valueHandler={setState}> + <InputSecured<T> name="auth_token" label="Access token" /> + </FormProvider> + ); +}; + +export const InitialValueNull = (): VNode => { + const [state, setState] = useState<Partial<T>>({ auth_token: null }); + return ( + <FormProvider<T> object={state} errors={{}} valueHandler={setState}> + Initial value: '' + <InputSecured<T> name="auth_token" label="Access token" /> + </FormProvider> + ); +}; diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx new file mode 100644 index 000000000..9d1a3ab8e --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputSecured.tsx @@ -0,0 +1,186 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { InputProps, useField } from "./useField.js"; + +export type Props<T> = InputProps<T>; + +const TokenStatus = ({ prev, post }: any) => { + const { i18n } = useTranslationContext(); + if ( + (prev === undefined || prev === null) && + (post === undefined || post === null) + ) + return null; + return prev === post ? null : post === null ? ( + <span class="tag is-danger is-align-self-center ml-2"> + <i18n.Translate>Deleting</i18n.Translate> + </span> + ) : ( + <span class="tag is-warning is-align-self-center ml-2"> + <i18n.Translate>Changing</i18n.Translate> + </span> + ); +}; + +export function InputSecured<T>({ + name, + readonly, + placeholder, + tooltip, + label, + help, +}: Props<keyof T>): VNode { + const { error, value, initial, onChange, toStr, fromStr } = useField<T>(name); + + const [active, setActive] = useState(false); + const [newValue, setNuewValue] = useState(""); + + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + {!active ? ( + <Fragment> + <div class="field has-addons"> + <button + class="button" + onClick={(): void => { + setActive(!active); + }} + > + <div class="icon is-left"> + <i class="mdi mdi-lock-reset" /> + </div> + <span> + <i18n.Translate>Manage access token</i18n.Translate> + </span> + </button> + <TokenStatus prev={initial} post={value} /> + </div> + </Fragment> + ) : ( + <Fragment> + <div class="field has-addons"> + <div class="control"> + <a class="button is-static">secret-token:</a> + </div> + <div class="control is-expanded"> + <input + class="input" + type="text" + placeholder={placeholder} + readonly={readonly || !active} + disabled={readonly || !active} + name={String(name)} + value={newValue} + onInput={(e): void => { + setNuewValue(e.currentTarget.value); + }} + /> + {help} + </div> + <div class="control"> + <button + class="button is-info" + disabled={fromStr(newValue) === value} + onClick={(): void => { + onChange(fromStr(newValue)); + setActive(!active); + setNuewValue(""); + }} + > + <div class="icon is-left"> + <i class="mdi mdi-lock-outline" /> + </div> + <span> + <i18n.Translate>Update</i18n.Translate> + </span> + </button> + </div> + </div> + </Fragment> + )} + {error ? <p class="help is-danger">{error}</p> : null} + </div> + </div> + {active && ( + <div class="field is-horizontal"> + <div class="field-body is-flex-grow-3"> + <div class="level" style={{ width: "100%" }}> + <div class="level-right is-flex-grow-1"> + <div class="level-item"> + <button + class="button is-danger" + disabled={null === value || undefined === value} + onClick={(): void => { + onChange(null!); + setActive(!active); + setNuewValue(""); + }} + > + <div class="icon is-left"> + <i class="mdi mdi-lock-open-variant" /> + </div> + <span> + <i18n.Translate>Remove</i18n.Translate> + </span> + </button> + </div> + <div class="level-item"> + <button + class="button " + onClick={(): void => { + onChange(initial!); + setActive(!active); + setNuewValue(""); + }} + > + <div class="icon is-left"> + <i class="mdi mdi-lock-open-variant" /> + </div> + <span> + <i18n.Translate>Cancel</i18n.Translate> + </span> + </button> + </div> + </div> + </div> + </div> + </div> + )} + </Fragment> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputSelector.tsx b/packages/auditor-backoffice-ui/src/components/form/InputSelector.tsx new file mode 100644 index 000000000..a8dad5d89 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputSelector.tsx @@ -0,0 +1,94 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, VNode } from "preact"; +import { InputProps, useField } from "./useField.js"; + +interface Props<T> extends InputProps<T> { + readonly?: boolean; + expand?: boolean; + values: any[]; + toStr?: (v?: any) => string; + fromStr?: (s: string) => any; +} + +const defaultToString = (f?: any): string => f || ""; +const defaultFromString = (v: string): any => v as any; + +export function InputSelector<T>({ + name, + readonly, + expand, + placeholder, + tooltip, + label, + help, + values, + fromStr = defaultFromString, + toStr = defaultToString, +}: Props<keyof T>): VNode { + const { error, value, onChange, required } = useField<T>(name); + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field has-icons-right"> + <p class={expand ? "control is-expanded select" : "control select "}> + <select + class={error ? "select is-danger" : "select"} + name={String(name)} + disabled={readonly} + readonly={readonly} + onChange={(e) => { + onChange(fromStr(e.currentTarget.value)); + }} + > + {placeholder && <option>{placeholder}</option>} + {values.map((v, i) => { + return ( + <option key={i} value={v} selected={value === v}> + {toStr(v)} + </option> + ); + })} + </select> + + {help} + </p> + {required && ( + <span class="icon has-text-danger is-right" style={{height: "2.5em"}}> + <i class="mdi mdi-alert" /> + </span> + )} + {error && <p class="help is-danger">{error}</p>} + </div> + </div> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx b/packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx new file mode 100644 index 000000000..668c65ea7 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputStock.stories.tsx @@ -0,0 +1,162 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { addDays } from "date-fns"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { FormProvider } from "./FormProvider.js"; +import { InputStock, Stock } from "./InputStock.js"; + +export default { + title: "Components/Form/InputStock", + component: InputStock, +}; + +type T = { stock?: Stock }; + +export const CreateStockEmpty = () => { + const [state, setState] = useState<Partial<T>>({}); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const CreateStockUnknownRestock = () => { + const [state, setState] = useState<Partial<T>>({ + stock: { + current: 10, + lost: 0, + sold: 0, + }, + }); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const CreateStockNoRestock = () => { + const [state, setState] = useState<Partial<T>>({ + stock: { + current: 10, + lost: 0, + sold: 0, + nextRestock: { t_s: "never" }, + }, + }); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const CreateStockWithRestock = () => { + const [state, setState] = useState<Partial<T>>({ + stock: { + current: 15, + lost: 0, + sold: 0, + nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 }, + }, + }); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const UpdatingProductWithManagedStock = () => { + const [state, setState] = useState<Partial<T>>({ + stock: { + current: 100, + lost: 0, + sold: 0, + nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 }, + }, + }); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" alreadyExist /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const UpdatingProductWithInfiniteStock = () => { + const [state, setState] = useState<Partial<T>>({}); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" alreadyExist /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; diff --git a/packages/auditor-backoffice-ui/src/components/form/InputStock.tsx b/packages/auditor-backoffice-ui/src/components/form/InputStock.tsx new file mode 100644 index 000000000..1d18685c5 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputStock.tsx @@ -0,0 +1,224 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h } from "preact"; +import { useLayoutEffect, useState } from "preact/hooks"; +import { MerchantBackend, Timestamp } from "../../declaration.js"; +import { FormErrors, FormProvider } from "./FormProvider.js"; +import { InputDate } from "./InputDate.js"; +import { InputGroup } from "./InputGroup.js"; +import { InputLocation } from "./InputLocation.js"; +import { InputNumber } from "./InputNumber.js"; +import { InputProps, useField } from "./useField.js"; + +export interface Props<T> extends InputProps<T> { + alreadyExist?: boolean; +} + +type Entity = Stock; + +export interface Stock { + current: number; + lost: number; + sold: number; + address?: MerchantBackend.Location; + nextRestock?: Timestamp; +} + +interface StockDelta { + incoming: number; + lost: number; +} + +export function InputStock<T>({ + name, + tooltip, + label, + alreadyExist, +}: Props<keyof T>) { + const { error, value, onChange } = useField<T>(name); + + const [errors, setErrors] = useState<FormErrors<Entity>>({}); + + const [formValue, valueHandler] = useState<Partial<Entity>>(value); + const [addedStock, setAddedStock] = useState<StockDelta>({ + incoming: 0, + lost: 0, + }); + const { i18n } = useTranslationContext(); + + useLayoutEffect(() => { + if (!formValue) { + onChange(undefined as any); + } else { + onChange({ + ...formValue, + current: (formValue?.current || 0) + addedStock.incoming, + lost: (formValue?.lost || 0) + addedStock.lost, + } as any); + } + }, [formValue, addedStock]); + + if (!formValue) { + return ( + <Fragment> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field has-addons"> + {!alreadyExist ? ( + <button + class="button" + data-tooltip={i18n.str`click here to configure the stock of the product, leave it as is and the backend will not control stock`} + onClick={(): void => { + valueHandler({ + current: 0, + lost: 0, + sold: 0, + } as Stock as any); + }} + > + <span> + <i18n.Translate>Manage stock</i18n.Translate> + </span> + </button> + ) : ( + <button + class="button" + data-tooltip={i18n.str`this product has been configured without stock control`} + disabled + > + <span> + <i18n.Translate>Infinite</i18n.Translate> + </span> + </button> + )} + </div> + </div> + </div> + </Fragment> + ); + } + + const currentStock = + (formValue.current || 0) - (formValue.lost || 0) - (formValue.sold || 0); + + const stockAddedErrors: FormErrors<typeof addedStock> = { + lost: + currentStock + addedStock.incoming < addedStock.lost + ? i18n.str`lost cannot be greater than current and incoming (max ${ + currentStock + addedStock.incoming + })` + : undefined, + }; + + // const stockUpdateDescription = stockAddedErrors.lost ? '' : ( + // !!addedStock.incoming || !!addedStock.lost ? + // i18n.str`current stock will change from ${currentStock} to ${currentStock + addedStock.incoming - addedStock.lost}` : + // i18n.str`current stock will stay at ${currentStock}` + // ) + + return ( + <Fragment> + <div class="card"> + <header class="card-header"> + <p class="card-header-title"> + {label} + {tooltip && ( + <span class="icon" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </p> + </header> + <div class="card-content"> + <FormProvider<Entity> + name="stock" + errors={errors} + object={formValue} + valueHandler={valueHandler} + > + {alreadyExist ? ( + <Fragment> + <FormProvider + name="added" + errors={stockAddedErrors} + object={addedStock} + valueHandler={setAddedStock as any} + > + <InputNumber name="incoming" label={i18n.str`Incoming`} /> + <InputNumber name="lost" label={i18n.str`Lost`} /> + </FormProvider> + + {/* <div class="field is-horizontal"> + <div class="field-label is-normal" /> + <div class="field-body is-flex-grow-3"> + <div class="field"> + {stockUpdateDescription} + </div> + </div> + </div> */} + </Fragment> + ) : ( + <InputNumber<Entity> + name="current" + label={i18n.str`Current`} + side={ + <button + class="button is-danger" + data-tooltip={i18n.str`remove stock control for this product`} + onClick={(): void => { + valueHandler(undefined as any); + }} + > + <span> + <i18n.Translate>without stock</i18n.Translate> + </span> + </button> + } + /> + )} + + <InputDate<Entity> + name="nextRestock" + label={i18n.str`Next restock`} + withTimestampSupport + /> + + <InputGroup<Entity> name="address" label={i18n.str`Warehouse address`}> + <InputLocation name="address" /> + </InputGroup> + </FormProvider> + </div> + </div> + </Fragment> + ); +} +// ( diff --git a/packages/auditor-backoffice-ui/src/components/form/InputTab.tsx b/packages/auditor-backoffice-ui/src/components/form/InputTab.tsx new file mode 100644 index 000000000..2701768aa --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputTab.tsx @@ -0,0 +1,90 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, VNode } from "preact"; +import { InputProps, useField } from "./useField.js"; + +interface Props<T> extends InputProps<T> { + readonly?: boolean; + expand?: boolean; + values: any[]; + toStr?: (v?: any) => string; + fromStr?: (s: string) => any; +} + +const defaultToString = (f?: any): string => f || ""; +const defaultFromString = (v: string): any => v as any; + +export function InputTab<T>({ + name, + readonly, + expand, + placeholder, + tooltip, + label, + help, + values, + fromStr = defaultFromString, + toStr = defaultToString, +}: Props<keyof T>): VNode { + const { error, value, onChange, required } = useField<T>(name); + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field has-icons-right"> + <p class={expand ? "control is-expanded " : "control "}> + <div class="tabs is-toggle is-fullwidth is-small"> + <ul> + {values.map((v, i) => { + return ( + <li key={i} class={value === v ? "is-active" : ""} + onClick={(e) => { onChange(v) }} + > + <a style={{ cursor: "initial" }}> + <span>{toStr(v)}</span> + </a> + </li> + ); + })} + </ul> + </div> + {help} + </p> + {required && ( + <span class="icon has-text-danger is-right" style={{ height: "2.5em" }}> + <i class="mdi mdi-alert" /> + </span> + )} + {error && <p class="help is-danger">{error}</p>} + </div> + </div> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx b/packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx new file mode 100644 index 000000000..b5722e4ec --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputTaxes.tsx @@ -0,0 +1,147 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useCallback, useState } from "preact/hooks"; +import * as yup from "yup"; +import { MerchantBackend } from "../../declaration.js"; +import { TaxSchema as schema } from "../../schemas/index.js"; +import { FormErrors, FormProvider } from "./FormProvider.js"; +import { Input } from "./Input.js"; +import { InputGroup } from "./InputGroup.js"; +import { InputProps, useField } from "./useField.js"; + +export interface Props<T> extends InputProps<T> { + isValid?: (e: any) => boolean; +} + +type Entity = MerchantBackend.Tax; +export function InputTaxes<T>({ + name, + readonly, + label, +}: Props<keyof T>): VNode { + const { value: taxes, onChange } = useField<T>(name); + + const [value, valueHandler] = useState<Partial<Entity>>({}); + // const [errors, setErrors] = useState<FormErrors<Entity>>({}) + + let errors: FormErrors<Entity> = {}; + + try { + schema.validateSync(value, { abortEarly: false }); + } catch (err) { + if (err instanceof yup.ValidationError) { + const yupErrors = err.inner as yup.ValidationError[]; + errors = yupErrors.reduce( + (prev, cur) => + !cur.path ? prev : { ...prev, [cur.path]: cur.message }, + {}, + ); + } + } + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submit = useCallback((): void => { + onChange([value as any, ...taxes] as any); + valueHandler({}); + }, [value]); + + const { i18n } = useTranslationContext(); + + //FIXME: translating plural singular + return ( + <InputGroup + name="tax" + label={label} + alternative={ + taxes.length > 0 && ( + <p>This product has {taxes.length} applicable taxes configured.</p> + ) + } + > + <FormProvider<Entity> + name="tax" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <div class="field is-horizontal"> + <div class="field-label is-normal" /> + <div class="field-body" style={{ display: "block" }}> + {taxes.map((v: any, i: number) => ( + <div + key={i} + class="tags has-addons mt-3 mb-0 mr-3" + style={{ flexWrap: "nowrap" }} + > + <span + class="tag is-medium is-info mb-0" + style={{ maxWidth: "90%" }} + > + <b>{v.tax}</b>: {v.name} + </span> + <a + class="tag is-medium is-danger is-delete mb-0" + onClick={() => { + onChange(taxes.filter((f: any) => f !== v) as any); + valueHandler(v); + }} + /> + </div> + ))} + {!taxes.length && i18n.str`No taxes configured for this product.`} + </div> + </div> + + <Input<Entity> + name="tax" + label={i18n.str`Amount`} + tooltip={i18n.str`Taxes can be in currencies that differ from the main currency used by the merchant.`} + > + <i18n.Translate> + Enter currency and value separated with a colon, e.g. + "USD:2.3". + </i18n.Translate> + </Input> + + <Input<Entity> + name="name" + label={i18n.str`Description`} + tooltip={i18n.str`Legal name of the tax, e.g. VAT or import duties.`} + /> + + <div class="buttons is-right mt-5"> + <button + class="button is-info" + data-tooltip={i18n.str`add tax to the tax list`} + disabled={hasErrors} + onClick={submit} + > + <i18n.Translate>Add</i18n.Translate> + </button> + </div> + </FormProvider> + </InputGroup> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputToggle.tsx b/packages/auditor-backoffice-ui/src/components/form/InputToggle.tsx new file mode 100644 index 000000000..f95dfcd05 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputToggle.tsx @@ -0,0 +1,91 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, VNode } from "preact"; +import { InputProps, useField } from "./useField.js"; + +interface Props<T> extends InputProps<T> { + name: T; + readonly?: boolean; + expand?: boolean; + threeState?: boolean; + toBoolean?: (v?: any) => boolean | undefined; + fromBoolean?: (s: boolean | undefined) => any; +} + +const defaultToBoolean = (f?: any): boolean | undefined => f || ""; +const defaultFromBoolean = (v: boolean | undefined): any => v as any; + +export function InputToggle<T>({ + name, + readonly, + placeholder, + tooltip, + label, + help, + threeState, + expand, + fromBoolean = defaultFromBoolean, + toBoolean = defaultToBoolean, +}: Props<keyof T>): VNode { + const { error, value, onChange } = useField<T>(name); + + const onCheckboxClick = (): void => { + const c = toBoolean(value); + if (c === false && threeState) return onChange(undefined as any); + return onChange(fromBoolean(!c)); + }; + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label" > + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class={expand ? "control is-expanded" : "control"}> + <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}> + <input + type="checkbox" + class={toBoolean(value) === undefined ? "is-indeterminate" : "toggle-checkbox"} + checked={toBoolean(value)} + placeholder={placeholder} + readonly={readonly} + name={String(name)} + disabled={readonly} + onChange={onCheckboxClick} + /> + <div class="toggle-switch"></div> + </label> + {help} + </p> + {error && <p class="help is-danger">{error}</p>} + </div> + </div> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/InputWithAddon.tsx b/packages/auditor-backoffice-ui/src/components/form/InputWithAddon.tsx new file mode 100644 index 000000000..e9fd88770 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/InputWithAddon.tsx @@ -0,0 +1,116 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ComponentChildren, h, VNode } from "preact"; +import { InputProps, useField } from "./useField.js"; + +export interface Props<T> extends InputProps<T> { + expand?: boolean; + inputType?: "text" | "number" | "password"; + addonBefore?: ComponentChildren; + addonAfter?: ComponentChildren; + addonAfterAction?: () => void; + toStr?: (v?: any) => string; + fromStr?: (s: string) => any; + inputExtra?: any; + children?: ComponentChildren; + side?: ComponentChildren; +} + +const defaultToString = (f?: any): string => f || ""; +const defaultFromString = (v: string): any => v as any; + +export function InputWithAddon<T>({ + name, + readonly, + addonBefore, + children, + expand, + label, + placeholder, + help, + tooltip, + inputType, + inputExtra, + side, + addonAfter, + addonAfterAction, + toStr = defaultToString, + fromStr = defaultFromString, +}: Props<keyof T>): VNode { + const { error, value, onChange, required } = useField<T>(name); + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <div class="field has-addons"> + {addonBefore && ( + <div class="control"> + <a class="button is-static">{addonBefore}</a> + </div> + )} + <p + class={`control${expand ? " is-expanded" : ""}${required ? " has-icons-right" : "" + }`} + > + <input + {...(inputExtra || {})} + class={error ? "input is-danger" : "input"} + type={inputType} + placeholder={placeholder} + readonly={readonly} + disabled={readonly} + name={String(name)} + value={toStr(value)} + onChange={(e): void => onChange(fromStr(e.currentTarget.value))} + /> + {required && ( + <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span> + )} + {children} + </p> + {addonAfter && ( + <div class="control" onClick={addonAfterAction} style={{ cursor: addonAfterAction ? "pointer" : undefined }}> + <a class="button is-static">{addonAfter}</a> + </div> + )} + </div> + {error && <p class="help is-danger">{error}</p>} + <span class="has-text-grey">{help}</span> + </div> + {expand ? <div>{side}</div> : side} + </div> + + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx b/packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx new file mode 100644 index 000000000..a0e1d6ae4 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/JumpToElementById.tsx @@ -0,0 +1,59 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; + +export function JumpToElementById({ testIfExist, onSelect, placeholder, description }: { placeholder: TranslatedString, description: TranslatedString, testIfExist: (id: string) => Promise<any>, onSelect: (id: string) => void }): VNode { + const { i18n } = useTranslationContext() + + const [error, setError] = useState<string | undefined>( + undefined, + ); + + const [id, setId] = useState<string>() + async function check(currentId: string | undefined): Promise<void> { + if (!currentId) { + setError(i18n.str`missing id`); + return; + } + try { + await testIfExist(currentId); + onSelect(currentId); + setError(undefined); + } catch { + setError(i18n.str`not found`); + } + } + + return <div class="level"> + <div class="level-left"> + <div class="level-item"> + <div class="field has-addons"> + <div class="control"> + <input + class={error ? "input is-danger" : "input"} + type="text" + value={id ?? ""} + onChange={(e) => setId(e.currentTarget.value)} + placeholder={placeholder} + /> + {error && <p class="help is-danger">{error}</p>} + </div> + <span + class="has-tooltip-bottom" + data-tooltip={description} + > + <button + class="button" + onClick={(e) => check(id)} + > + <span class="icon"> + <i class="mdi mdi-arrow-right" /> + </span> + </button> + </span> + </div> + </div> + </div> + </div> +} diff --git a/packages/auditor-backoffice-ui/src/components/form/TextField.tsx b/packages/auditor-backoffice-ui/src/components/form/TextField.tsx new file mode 100644 index 000000000..03f36dcbb --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/TextField.tsx @@ -0,0 +1,71 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ComponentChildren, h, VNode } from "preact"; +import { useField, InputProps } from "./useField.js"; + +interface Props<T> extends InputProps<T> { + inputType?: "text" | "number" | "multiline" | "password"; + expand?: boolean; + side?: ComponentChildren; + children: ComponentChildren; +} + +export function TextField<T>({ + name, + tooltip, + label, + expand, + help, + children, + side, +}: Props<keyof T>): VNode { + const { error } = useField<T>(name); + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p + class={ + expand + ? "control is-expanded has-icons-right" + : "control has-icons-right" + } + > + {children} + {help} + </p> + {error && <p class="help is-danger">{error}</p>} + </div> + {side} + </div> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/form/useField.tsx b/packages/auditor-backoffice-ui/src/components/form/useField.tsx new file mode 100644 index 000000000..c7559faae --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/useField.tsx @@ -0,0 +1,92 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ComponentChildren, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useFormContext } from "./FormProvider.js"; + +interface Use<V> { + error?: string; + required: boolean; + value: any; + initial: any; + onChange: (v: V) => void; + toStr: (f: V | undefined) => string; + fromStr: (v: string) => V; +} + +export function useField<T>(name: keyof T): Use<T[typeof name]> { + const { errors, object, initialObject, toStr, fromStr, valueHandler } = + useFormContext<T>(); + type P = typeof name; + type V = T[P]; + const [isDirty, setDirty] = useState(false); + const updateField = + (field: P) => + (value: V): void => { + setDirty(true); + return valueHandler((prev) => { + return setValueDeeper(prev, String(field).split("."), value); + }); + }; + + const defaultToString = (f?: V): string => String(!f ? "" : f); + const defaultFromString = (v: string): V => v as any; + const value = readField(object, String(name)); + const initial = readField(initialObject, String(name)); + const hasError = readField(errors, String(name)); + return { + error: isDirty ? hasError : undefined, + required: !isDirty && hasError, + value, + initial, + onChange: updateField(name) as any, + toStr: toStr[name] ? toStr[name]! : defaultToString, + fromStr: fromStr[name] ? fromStr[name]! : defaultFromString, + }; +} +/** + * read the field of an object an support accessing it using '.' + * + * @param object + * @param name + * @returns + */ +const readField = (object: any, name: string) => { + return name + .split(".") + .reduce((prev, current) => prev && prev[current], object); +}; + +const setValueDeeper = (object: any, names: string[], value: any): any => { + if (names.length === 0) return value; + const [head, ...rest] = names; + return { ...object, [head]: setValueDeeper(object[head] || {}, rest, value) }; +}; + +export interface InputProps<T> { + name: T; + label: ComponentChildren; + placeholder?: string; + tooltip?: ComponentChildren; + readonly?: boolean; + help?: ComponentChildren; +} diff --git a/packages/auditor-backoffice-ui/src/components/form/useGroupField.tsx b/packages/auditor-backoffice-ui/src/components/form/useGroupField.tsx new file mode 100644 index 000000000..9a445eb32 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/form/useGroupField.tsx @@ -0,0 +1,41 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useFormContext } from "./FormProvider.js"; + +interface Use { + hasError?: boolean; +} + +export function useGroupField<T>(name: keyof T): Use { + const f = useFormContext<T>(); + if (!f) return {}; + + return { + hasError: readField(f.errors, String(name)), + }; +} + +const readField = (object: any, name: string) => { + return name + .split(".") + .reduce((prev, current) => prev && prev[current], object); +}; diff --git a/packages/auditor-backoffice-ui/src/components/index.stories.ts b/packages/auditor-backoffice-ui/src/components/index.stories.ts new file mode 100644 index 000000000..c57ddab14 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/index.stories.ts @@ -0,0 +1,17 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +export * as payto from "./form/InputPaytoForm.stories.js"; diff --git a/packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx new file mode 100644 index 000000000..6f5881fc0 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx @@ -0,0 +1,124 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useBackendContext } from "../../context/backend.js"; +import { Entity } from "../../paths/admin/create/CreatePage.js"; +import { Input } from "../form/Input.js"; +import { InputDuration } from "../form/InputDuration.js"; +import { InputGroup } from "../form/InputGroup.js"; +import { InputImage } from "../form/InputImage.js"; +import { InputLocation } from "../form/InputLocation.js"; +import { InputSelector } from "../form/InputSelector.js"; +import { InputToggle } from "../form/InputToggle.js"; +import { InputWithAddon } from "../form/InputWithAddon.js"; + +export function DefaultInstanceFormFields({ + readonlyId, + showId, +}: { + readonlyId?: boolean; + showId: boolean; +}): VNode { + const { i18n } = useTranslationContext(); + const { url: backendURL } = useBackendContext() + return ( + <Fragment> + {showId && ( + <InputWithAddon<Entity> + name="id" + addonBefore={`${backendURL}/instances/`} + readonly={readonlyId} + label={i18n.str`Identifier`} + tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`} + /> + )} + + <Input<Entity> + name="name" + label={i18n.str`Business name`} + tooltip={i18n.str`Legal name of the business represented by this instance.`} + /> + + <InputSelector<Entity> + name="user_type" + label={i18n.str`Type`} + tooltip={i18n.str`Different type of account can have different rules and requirements.`} + values={["business", "individual"]} + /> + + <Input<Entity> + name="email" + label={i18n.str`Email`} + tooltip={i18n.str`Contact email`} + /> + + <Input<Entity> + name="website" + label={i18n.str`Website URL`} + tooltip={i18n.str`URL.`} + /> + + <InputImage<Entity> + name="logo" + label={i18n.str`Logo`} + tooltip={i18n.str`Logo image.`} + /> + + <InputToggle<Entity> + name="use_stefan" + label={i18n.str`Pay transaction fee`} + tooltip={i18n.str`Assume the cost of the transaction of let the user pay for it.`} + /> + + <InputGroup + name="address" + label={i18n.str`Address`} + tooltip={i18n.str`Physical location of the merchant.`} + > + <InputLocation name="address" /> + </InputGroup> + + <InputGroup + name="jurisdiction" + label={i18n.str`Jurisdiction`} + tooltip={i18n.str`Jurisdiction for legal disputes with the merchant.`} + > + <InputLocation name="jurisdiction" /> + </InputGroup> + + <InputDuration<Entity> + name="default_pay_delay" + label={i18n.str`Default payment delay`} + withForever + tooltip={i18n.str`Time customers have to pay an order before the offer expires by default.`} + /> + + <InputDuration<Entity> + name="default_wire_transfer_delay" + label={i18n.str`Default wire transfer delay`} + tooltip={i18n.str`Maximum time an exchange is allowed to delay wiring funds to the merchant, enabling it to aggregate smaller payments into larger wire transfers and reducing wire fees.`} + withForever + /> + </Fragment> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx b/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx new file mode 100644 index 000000000..41fe1374a --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/menu/LangSelector.tsx @@ -0,0 +1,92 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import langIcon from "../../assets/icons/languageicon.svg"; +import { strings as messages } from "../../i18n/strings.js"; + +type LangsNames = { + [P in keyof typeof messages]: string; +}; + +const names: LangsNames = { + es: "Español [es]", + en: "English [en]", + fr: "Français [fr]", + de: "Deutsch [de]", + sv: "Svenska [sv]", + it: "Italiano [it]", +}; + +function getLangName(s: keyof LangsNames | string) { + if (names[s]) return names[s]; + return s; +} + +export function LangSelector(): VNode { + const [updatingLang, setUpdatingLang] = useState(false); + const { lang, changeLanguage } = useTranslationContext(); + + return ( + <div class="dropdown is-active "> + <div class="dropdown-trigger"> + <button + class="button has-tooltip-left" + data-tooltip="change language selection" + aria-haspopup="true" + aria-controls="dropdown-menu" + onClick={() => setUpdatingLang(!updatingLang)} + > + <div class="icon is-small is-left"> + <img src={langIcon} /> + </div> + <span>{getLangName(lang)}</span> + <div class="icon is-right"> + <i class="mdi mdi-chevron-down" /> + </div> + </button> + </div> + {updatingLang && ( + <div class="dropdown-menu" id="dropdown-menu" role="menu"> + <div class="dropdown-content"> + {Object.keys(messages) + .filter((l) => l !== lang) + .map((l) => ( + <a + key={l} + class="dropdown-item" + value={l} + onClick={() => { + changeLanguage(l); + setUpdatingLang(false); + }} + > + {getLangName(l)} + </a> + ))} + </div> + </div> + )} + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx b/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx new file mode 100644 index 000000000..9f1b33893 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/menu/NavigationBar.tsx @@ -0,0 +1,72 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from "preact"; +import logo from "../../assets/logo-2021.svg"; + +interface Props { + onMobileMenu: () => void; + title: string; +} + +export function NavigationBar({ onMobileMenu, title }: Props): VNode { + return ( + <nav + class="navbar is-fixed-top" + role="navigation" + aria-label="main navigation" + > + <div class="navbar-brand"> + <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}> + {title} + </span> + + <a + role="button" + class="navbar-burger" + aria-label="menu" + aria-expanded="false" + onClick={(e) => { + onMobileMenu(); + e.stopPropagation(); + }} + > + <span aria-hidden="true" /> + <span aria-hidden="true" /> + <span aria-hidden="true" /> + </a> + </div> + + <div class="navbar-menu "> + <a + class="navbar-start is-justify-content-center is-flex-grow-1" + href="https://taler.net" + > + <img src={logo} style={{ height: 35, margin: 10 }} /> + </a> + <div class="navbar-end"> + <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> + </div> + </div> + </div> + </nav> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx b/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx new file mode 100644 index 000000000..cfc00148e --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/menu/SideBar.tsx @@ -0,0 +1,284 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useBackendContext } from "../../context/backend.js"; +import { useConfigContext } from "../../context/config.js"; +import { useInstanceKYCDetails } from "../../hooks/instance.js"; +import { LangSelector } from "./LangSelector.js"; + +const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; +const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; + +interface Props { + onLogout: () => void; + onShowSettings: () => void; + mobile?: boolean; + instance: string; + admin?: boolean; + mimic?: boolean; + isPasswordOk: boolean; +} + +export function Sidebar({ + mobile, + instance, + onShowSettings, + onLogout, + admin, + mimic, + isPasswordOk +}: Props): VNode { + const config = useConfigContext(); + const { url: backendURL } = useBackendContext() + const { i18n } = useTranslationContext(); + const kycStatus = useInstanceKYCDetails(); + const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; + + return ( + <aside class="aside is-placed-left is-expanded" style={{ overflowY: "scroll" }}> + {mobile && ( + <div + class="footer" + onClick={(e) => { + return e.stopImmediatePropagation(); + }} + > + <LangSelector /> + </div> + )} + <div class="aside-tools"> + <div class="aside-tools-label"> + <div> + <b>Taler</b> Backoffice + </div> + <div + class="is-size-7 has-text-right" + style={{ lineHeight: 0, marginTop: -10 }} + > + {VERSION} ({config.version}) + </div> + </div> + </div> + <div class="menu is-menu-main"> + {instance ? ( + <Fragment> + <ul class="menu-list"> + <li> + <a href={"/orders"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-cash-register" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Orders</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/inventory"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-shopping" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Inventory</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/transfers"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-arrow-left-right" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Transfers</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/templates"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-newspaper" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Templates</i18n.Translate> + </span> + </a> + </li> + {needKYC && ( + <li> + <a href={"/kyc"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-account-check" /> + </span> + <span class="menu-item-label">KYC Status</span> + </a> + </li> + )} + </ul> + <p class="menu-label"> + <i18n.Translate>Configuration</i18n.Translate> + </p> + <ul class="menu-list"> + <li> + <a href={"/bank"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-bank" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Bank account</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/otp-devices"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-lock" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>OTP Devices</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/reserves"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-cash" /> + </span> + <span class="menu-item-label">Reserves</span> + </a> + </li> + <li> + <a href={"/webhooks"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-newspaper" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Webhooks</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/settings"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-square-edit-outline" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Settings</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/token"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-security" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Access token</i18n.Translate> + </span> + </a> + </li> + </ul> + </Fragment> + ) : undefined} + <p class="menu-label"> + <i18n.Translate>Connection</i18n.Translate> + </p> + <ul class="menu-list"> + <li> + <a class="has-icon is-state-info is-hoverable" + onClick={(): void => onShowSettings()} + > + <span class="icon"> + <i class="mdi mdi-newspaper" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Interface</i18n.Translate> + </span> + </a> + </li> + <li> + <div> + <span style={{ width: "3rem" }} class="icon"> + <i class="mdi mdi-web" /> + </span> + <span class="menu-item-label"> + {new URL(backendURL).hostname} + </span> + </div> + </li> + <li> + <div> + <span style={{ width: "3rem" }} class="icon"> + ID + </span> + <span class="menu-item-label"> + {!instance ? "default" : instance} + </span> + </div> + </li> + {admin && !mimic && ( + <Fragment> + <p class="menu-label"> + <i18n.Translate>Instances</i18n.Translate> + </p> + <li> + <a href={"/instance/new"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-plus" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>New</i18n.Translate> + </span> + </a> + </li> + <li> + <a href={"/instances"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-format-list-bulleted" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>List</i18n.Translate> + </span> + </a> + </li> + </Fragment> + )} + {isPasswordOk ? + <li> + <a + class="has-icon is-state-info is-hoverable" + onClick={(): void => onLogout()} + > + <span class="icon"> + <i class="mdi mdi-logout default" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Log out</i18n.Translate> + </span> + </a> + </li> : undefined + } + </ul> + </div> + </aside> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/menu/index.tsx b/packages/auditor-backoffice-ui/src/components/menu/index.tsx new file mode 100644 index 000000000..015d3bd05 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/menu/index.tsx @@ -0,0 +1,237 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { ComponentChildren, Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { AdminPaths } from "../../AdminRoutes.js"; +import { InstancePaths } from "../../InstanceRoutes.js"; +import { Notification } from "../../utils/types.js"; +import { NavigationBar } from "./NavigationBar.js"; +import { Sidebar } from "./SideBar.js"; + +function getInstanceTitle(path: string, id: string): string { + switch (path) { + case InstancePaths.settings: + return `${id}: Settings`; + case InstancePaths.inventory_list: + return `${id}: Inventory`; + case InstancePaths.deposit_confirmation_list: + return `${id}: Deposit Confirmation`; + case InstancePaths.inventory_new: + return `${id}: New product`; + case InstancePaths.inventory_update: + return `${id}: Update product`; + case InstancePaths.interface: + return `${id}: Interface`; + default: + return ""; + } +} + +function getAdminTitle(path: string, instance: string) { + if (path === AdminPaths.new_instance) return `New instance`; + if (path === AdminPaths.list_instances) return `Instances`; + return getInstanceTitle(path, instance); +} + +interface MenuProps { + title?: string; + path: string; + instance: string; + admin?: boolean; + onLogout?: () => void; + onShowSettings: () => void; + setInstanceName: (s: string) => void; + isPasswordOk: boolean; +} + +function WithTitle({ + title, + children, +}: { + title: string; + children: ComponentChildren; +}): VNode { + useEffect(() => { + document.title = `Taler Backoffice: ${title}`; + }, [title]); + return <Fragment>{children}</Fragment>; +} + +export function Menu({ + onLogout, + onShowSettings, + title, + instance, + path, + admin, + setInstanceName, + isPasswordOk +}: MenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); + + const titleWithSubtitle = title + ? title + : !admin + ? getInstanceTitle(path, instance) + : getAdminTitle(path, instance); + const adminInstance = instance === "default"; + const mimic = admin && !adminInstance; + return ( + <WithTitle title={titleWithSubtitle}> + <div + class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={titleWithSubtitle} + /> + + {onLogout && ( + <Sidebar + onShowSettings={onShowSettings} + onLogout={onLogout} + admin={admin} + mimic={mimic} + instance={instance} + mobile={mobileOpen} + isPasswordOk={isPasswordOk} + /> + )} + + {mimic && ( + <nav class="level" style={{ + zIndex: 100, + position: "fixed", + width: "50%", + marginLeft: "20%" + }}> + <div class="level-item has-text-centered has-background-warning"> + <p class="is-size-5"> + You are viewing the instance <b>"{instance}"</b>.{" "} + <a + href="#/instances" + onClick={(e) => { + setInstanceName("default"); + }} + > + go back + </a> + </p> + </div> + </nav> + )} + </div> + </WithTitle> + ); +} + +interface NotYetReadyAppMenuProps { + title: string; + onShowSettings: () => void; + onLogout?: () => void; + isPasswordOk: boolean; +} + +interface NotifProps { + notification?: Notification; +} +export function NotificationCard({ + notification: n, +}: NotifProps): VNode | null { + if (!n) return null; + return ( + <div class="notification"> + <div class="columns is-vcentered"> + <div class="column is-12"> + <article + class={ + n.type === "ERROR" + ? "message is-danger" + : n.type === "WARN" + ? "message is-warning" + : "message is-info" + } + > + <div class="message-header"> + <p>{n.message}</p> + </div> + {n.description && ( + <div class="message-body"> + <div>{n.description}</div> + {n.details && <pre>{n.details}</pre>} + </div> + )} + </article> + </div> + </div> + </div> + ); +} + +interface NotConnectedAppMenuProps { + title: string; +} +export function NotConnectedAppMenu({ + title, +}: NotConnectedAppMenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); + + useEffect(() => { + document.title = `Taler Backoffice: ${title}`; + }, [title]); + + return ( + <div + class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={title} + /> + </div> + ); +} + +export function NotYetReadyAppMenu({ + onLogout, + onShowSettings, + title, + isPasswordOk +}: NotYetReadyAppMenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); + + useEffect(() => { + document.title = `Taler Backoffice: ${title}`; + }, [title]); + + return ( + <div + class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={title} + /> + {onLogout && ( + <Sidebar onShowSettings={onShowSettings} onLogout={onLogout} instance="" mobile={mobileOpen} isPasswordOk={isPasswordOk} /> + )} + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/modal/index.tsx b/packages/auditor-backoffice-ui/src/components/modal/index.tsx new file mode 100644 index 000000000..8372c84cc --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/modal/index.tsx @@ -0,0 +1,496 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useInstanceContext } from "../../context/instance.js"; +import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js"; +import { Spinner } from "../exception/loading.js"; +import { FormProvider } from "../form/FormProvider.js"; +import { Input } from "../form/Input.js"; + +interface Props { + active?: boolean; + description?: string; + onCancel?: () => void; + onConfirm?: () => void; + label?: string; + children?: ComponentChildren; + danger?: boolean; + disabled?: boolean; +} + +export function ConfirmModal({ + active, + description, + onCancel, + onConfirm, + children, + danger, + disabled, + label = "Confirm", +}: Props): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class={active ? "modal is-active" : "modal"}> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card" style={{ maxWidth: 700 }}> + <header class="modal-card-head"> + {!description ? null : ( + <p class="modal-card-title"> + <b>{description}</b> + </p> + )} + <button class="delete " aria-label="close" onClick={onCancel} /> + </header> + <section class="modal-card-body">{children}</section> + <footer class="modal-card-foot"> + <div class="buttons is-right" style={{ width: "100%" }}> + {onConfirm ? ( + <Fragment> + <button class="button " onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + + <button + class={danger ? "button is-danger " : "button is-info "} + disabled={disabled} + onClick={onConfirm} + > + <i18n.Translate>{label}</i18n.Translate> + </button> + </Fragment> + ) : ( + <button class="button " onClick={onCancel}> + <i18n.Translate>Close</i18n.Translate> + </button> + )} + </div> + </footer> + </div> + <button + class="modal-close is-large " + aria-label="close" + onClick={onCancel} + /> + </div> + ); +} + +export function ContinueModal({ + active, + description, + onCancel, + onConfirm, + children, + disabled, +}: Props): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class={active ? "modal is-active" : "modal"}> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card"> + <header class="modal-card-head has-background-success"> + {!description ? null : <p class="modal-card-title">{description}</p>} + <button class="delete " aria-label="close" onClick={onCancel} /> + </header> + <section class="modal-card-body">{children}</section> + <footer class="modal-card-foot"> + <div class="buttons is-right" style={{ width: "100%" }}> + <button + class="button is-success " + disabled={disabled} + onClick={onConfirm} + > + <i18n.Translate>Continue</i18n.Translate> + </button> + </div> + </footer> + </div> + <button + class="modal-close is-large " + aria-label="close" + onClick={onCancel} + /> + </div> + ); +} + +export function SimpleModal({ onCancel, children }: any): VNode { + return ( + <div class="modal is-active"> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card"> + <section class="modal-card-body is-main-section">{children}</section> + </div> + <button + class="modal-close is-large " + aria-label="close" + onClick={onCancel} + /> + </div> + ); +} + +export function ClearConfirmModal({ + description, + onCancel, + onClear, + onConfirm, + children, +}: Props & { onClear?: () => void }): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="modal is-active"> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card"> + <header class="modal-card-head"> + {!description ? null : <p class="modal-card-title">{description}</p>} + <button class="delete " aria-label="close" onClick={onCancel} /> + </header> + <section class="modal-card-body is-main-section">{children}</section> + <footer class="modal-card-foot"> + {onClear && ( + <button + class="button is-danger" + onClick={onClear} + disabled={onClear === undefined} + > + <i18n.Translate>Clear</i18n.Translate> + </button> + )} + <div class="buttons is-right" style={{ width: "100%" }}> + <button class="button " onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + <button + class="button is-info" + onClick={onConfirm} + disabled={onConfirm === undefined} + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> + </footer> + </div> + <button + class="modal-close is-large " + aria-label="close" + onClick={onCancel} + /> + </div> + ); +} + +interface DeleteModalProps { + element: { id: string; name: string }; + onCancel: () => void; + onConfirm: (id: string) => void; +} + +export function DeleteModal({ + element, + onCancel, + onConfirm, +}: DeleteModalProps): VNode { + return ( + <ConfirmModal + label={`Delete instance`} + description={`Delete the instance "${element.name}"`} + danger + active + onCancel={onCancel} + onConfirm={() => onConfirm(element.id)} + > + <p> + If you delete the instance named <b>"{element.name}"</b> (ID:{" "} + <b>{element.id}</b>), the merchant will no longer be able to process + orders or refunds + </p> + <p> + This action deletes the instance private key, but preserves all + transaction data. You can still access that data after deleting the + instance. + </p> + <p class="warning"> + Deleting an instance <b>cannot be undone</b>. + </p> + </ConfirmModal> + ); +} + +export function PurgeModal({ + element, + onCancel, + onConfirm, +}: DeleteModalProps): VNode { + return ( + <ConfirmModal + label={`Purge the instance`} + description={`Purge the instance "${element.name}"`} + danger + active + onCancel={onCancel} + onConfirm={() => onConfirm(element.id)} + > + <p> + If you purge the instance named <b>"{element.name}"</b> (ID:{" "} + <b>{element.id}</b>), you will also delete all it's transaction + data. + </p> + <p> + The instance will disappear from your list, and you will no longer be + able to access it's data. + </p> + <p class="warning"> + Purging an instance <b>cannot be undone</b>. + </p> + </ConfirmModal> + ); +} + +interface UpdateTokenModalProps { + oldToken?: string; + onCancel: () => void; + onConfirm: (value: string) => void; + onClear: () => void; +} + +//FIXME: merge UpdateTokenModal with SetTokenNewInstanceModal +export function UpdateTokenModal({ + onCancel, + onClear, + onConfirm, + oldToken, +}: UpdateTokenModalProps): VNode { + type State = { old_token: string; new_token: string; repeat_token: string }; + const [form, setValue] = useState<Partial<State>>({ + old_token: "", + new_token: "", + repeat_token: "", + }); + const { i18n } = useTranslationContext(); + + const hasInputTheCorrectOldToken = oldToken && oldToken !== form.old_token; + const errors = { + old_token: hasInputTheCorrectOldToken + ? i18n.str`is not the same as the current access token` + : undefined, + new_token: !form.new_token + ? i18n.str`cannot be empty` + : form.new_token === form.old_token + ? i18n.str`cannot be the same as the old token` + : undefined, + repeat_token: + form.new_token !== form.repeat_token + ? i18n.str`is not the same` + : undefined, + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const instance = useInstanceContext(); + + const text = i18n.str`You are updating the access token from instance with id ${instance.id}`; + + return ( + <ClearConfirmModal + description={text} + onCancel={onCancel} + onConfirm={!hasErrors ? () => onConfirm(form.new_token!) : undefined} + onClear={!hasInputTheCorrectOldToken && oldToken ? onClear : undefined} + > + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider errors={errors} object={form} valueHandler={setValue}> + {oldToken && ( + <Input<State> + name="old_token" + label={i18n.str`Old access token`} + tooltip={i18n.str`access token currently in use`} + inputType="password" + /> + )} + <Input<State> + name="new_token" + label={i18n.str`New access token`} + tooltip={i18n.str`next access token to be used`} + inputType="password" + /> + <Input<State> + name="repeat_token" + label={i18n.str`Repeat access token`} + tooltip={i18n.str`confirm the same access token`} + inputType="password" + /> + </FormProvider> + <p> + <i18n.Translate> + Clearing the access token will mean public access to the instance + </i18n.Translate> + </p> + </div> + <div class="column" /> + </div> + </ClearConfirmModal> + ); +} + +export function SetTokenNewInstanceModal({ + onCancel, + onClear, + onConfirm, +}: UpdateTokenModalProps): VNode { + type State = { old_token: string; new_token: string; repeat_token: string }; + const [form, setValue] = useState<Partial<State>>({ + new_token: "", + repeat_token: "", + }); + const { i18n } = useTranslationContext(); + + const errors = { + new_token: !form.new_token + ? i18n.str`cannot be empty` + : form.new_token === form.old_token + ? i18n.str`cannot be the same as the old access token` + : undefined, + repeat_token: + form.new_token !== form.repeat_token + ? i18n.str`is not the same` + : undefined, + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + return ( + <div class="modal is-active"> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title">{i18n.str`You are setting the access token for the new instance`}</p> + <button class="delete " aria-label="close" onClick={onCancel} /> + </header> + <section class="modal-card-body is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider + errors={errors} + object={form} + valueHandler={setValue} + > + <Input<State> + name="new_token" + label={i18n.str`New access token`} + tooltip={i18n.str`next access token to be used`} + inputType="password" + /> + <Input<State> + name="repeat_token" + label={i18n.str`Repeat access token`} + tooltip={i18n.str`confirm the same access token`} + inputType="password" + /> + </FormProvider> + <p> + <i18n.Translate> + With external authorization method no check will be done by + the merchant backend + </i18n.Translate> + </p> + </div> + <div class="column" /> + </div> + </section> + <footer class="modal-card-foot"> + {onClear && ( + <button + class="button is-danger" + onClick={onClear} + disabled={onClear === undefined} + > + <i18n.Translate>Set external authorization</i18n.Translate> + </button> + )} + <div class="buttons is-right" style={{ width: "100%" }}> + <button class="button " onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + <button + class="button is-info" + onClick={() => onConfirm(form.new_token!)} + disabled={hasErrors} + > + <i18n.Translate>Set access token</i18n.Translate> + </button> + </div> + </footer> + </div> + <button + class="modal-close is-large " + aria-label="close" + onClick={onCancel} + /> + </div> + ); +} + +export function LoadingModal({ onCancel }: { onCancel: () => void }): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="modal is-active"> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title"> + <i18n.Translate>Operation in progress...</i18n.Translate> + </p> + </header> + <section class="modal-card-body"> + <div class="columns"> + <div class="column" /> + <Spinner /> + <div class="column" /> + </div> + <p>{i18n.str`The operation will be automatically canceled after ${DEFAULT_REQUEST_TIMEOUT} seconds`}</p> + </section> + <footer class="modal-card-foot"> + <div class="buttons is-right" style={{ width: "100%" }}> + <button class="button " onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + </div> + </footer> + </div> + <button + class="modal-close is-large " + aria-label="close" + onClick={onCancel} + /> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx new file mode 100644 index 000000000..073382fb1 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/notifications/CreatedSuccessfully.tsx @@ -0,0 +1,57 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ComponentChildren, h, VNode } from "preact"; + +interface Props { + onCreateAnother?: () => void; + onConfirm: () => void; + children: ComponentChildren; +} + +export function CreatedSuccessfully({ + children, + onConfirm, + onCreateAnother, +}: Props): VNode { + return ( + <div class="columns is-fullwidth is-vcentered mt-3"> + <div class="column" /> + <div class="column is-four-fifths"> + <div class="card"> + <header class="card-header has-background-success"> + <p class="card-header-title has-text-white-ter">Success.</p> + </header> + <div class="card-content">{children}</div> + </div> + <div class="buttons is-right"> + {onCreateAnother && ( + <button class="button is-info" onClick={onCreateAnother}> + Create another + </button> + )} + <button class="button is-info" onClick={onConfirm}> + Continue + </button> + </div> + </div> + <div class="column" /> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx b/packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx new file mode 100644 index 000000000..af594de0f --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/notifications/Notifications.stories.tsx @@ -0,0 +1,62 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h } from "preact"; +import { Notifications } from "./index.js"; + +export default { + title: "Components/Notification", + component: Notifications, + argTypes: { + removeNotification: { action: "removeNotification" }, + }, +}; + +export const Info = (a: any) => <Notifications {...a} />; +Info.args = { + notifications: [ + { + message: "Title", + description: "Some large description", + type: "INFO", + }, + ], +}; +export const Warn = (a: any) => <Notifications {...a} />; +Warn.args = { + notifications: [ + { + message: "Title", + description: "Some large description", + type: "WARN", + }, + ], +}; +export const Error = (a: any) => <Notifications {...a} />; +Error.args = { + notifications: [ + { + message: "Title", + description: "Some large description", + type: "ERROR", + }, + ], +}; diff --git a/packages/auditor-backoffice-ui/src/components/notifications/index.tsx b/packages/auditor-backoffice-ui/src/components/notifications/index.tsx new file mode 100644 index 000000000..235c75577 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/notifications/index.tsx @@ -0,0 +1,65 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode } from "preact"; +import { MessageType, Notification } from "../../utils/types.js"; + +interface Props { + notifications: Notification[]; + removeNotification?: (n: Notification) => void; +} + +function messageStyle(type: MessageType): string { + switch (type) { + case "INFO": + return "message is-info"; + case "WARN": + return "message is-warning"; + case "ERROR": + return "message is-danger"; + case "SUCCESS": + return "message is-success"; + default: + return "message"; + } +} + +export function Notifications({ + notifications, + removeNotification, +}: Props): VNode { + return ( + <div class="toast"> + {notifications.map((n, i) => ( + <article key={i} class={messageStyle(n.type)}> + <div class="message-header"> + <p>{n.message}</p> + <button + class="delete" + onClick={() => removeNotification && removeNotification(n)} + /> + </div> + {n.description && <div class="message-body">{n.description}</div>} + </article> + ))} + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx b/packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx new file mode 100644 index 000000000..0bc629d46 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/picker/DatePicker.tsx @@ -0,0 +1,349 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, Component } from "preact"; + +interface Props { + closeFunction?: () => void; + dateReceiver?: (d: Date) => void; + opened?: boolean; +} +interface State { + displayedMonth: number; + displayedYear: number; + selectYearMode: boolean; + currentDate: Date; +} + +// inspired by https://codepen.io/m4r1vs/pen/MOOxyE +export class DatePicker extends Component<Props, State> { + closeDatePicker() { + this.props.closeFunction && this.props.closeFunction(); // Function gets passed by parent + } + + /** + * Gets fired when a day gets clicked. + * @param {object} e The event thrown by the <span /> element clicked + */ + dayClicked(e: any) { + const element = e.target; // the actual element clicked + + if (element.innerHTML === "") return false; // don't continue if <span /> empty + + // get date from clicked element (gets attached when rendered) + const date = new Date(element.getAttribute("data-value")); + + // update the state + this.setState({ currentDate: date }); + this.passDateToParent(date); + } + + /** + * returns days in month as array + * @param {number} month the month to display + * @param {number} year the year to display + */ + getDaysByMonth(month: number, year: number) { + const calendar = []; + + const date = new Date(year, month, 1); // month to display + + const firstDay = new Date(year, month, 1).getDay(); // first weekday of month + const lastDate = new Date(year, month + 1, 0).getDate(); // last date of month + + let day: number | null = 0; + + // the calendar is 7*6 fields big, so 42 loops + for (let i = 0; i < 42; i++) { + if (i >= firstDay && day !== null) day = day + 1; + if (day !== null && day > lastDate) day = null; + + // append the calendar Array + calendar.push({ + day: day === 0 || day === null ? null : day, // null or number + date: day === 0 || day === null ? null : new Date(year, month, day), // null or Date() + today: + day === now.getDate() && + month === now.getMonth() && + year === now.getFullYear(), // boolean + }); + } + + return calendar; + } + + /** + * Display previous month by updating state + */ + displayPrevMonth() { + if (this.state.displayedMonth <= 0) { + this.setState({ + displayedMonth: 11, + displayedYear: this.state.displayedYear - 1, + }); + } else { + this.setState({ + displayedMonth: this.state.displayedMonth - 1, + }); + } + } + + /** + * Display next month by updating state + */ + displayNextMonth() { + if (this.state.displayedMonth >= 11) { + this.setState({ + displayedMonth: 0, + displayedYear: this.state.displayedYear + 1, + }); + } else { + this.setState({ + displayedMonth: this.state.displayedMonth + 1, + }); + } + } + + /** + * Display the selected month (gets fired when clicking on the date string) + */ + displaySelectedMonth() { + if (this.state.selectYearMode) { + this.toggleYearSelector(); + } else { + if (!this.state.currentDate) return false; + this.setState({ + displayedMonth: this.state.currentDate.getMonth(), + displayedYear: this.state.currentDate.getFullYear(), + }); + } + } + + toggleYearSelector() { + this.setState({ selectYearMode: !this.state.selectYearMode }); + } + + changeDisplayedYear(e: any) { + const element = e.target; + this.toggleYearSelector(); + this.setState({ + displayedYear: parseInt(element.innerHTML, 10), + displayedMonth: 0, + }); + } + + /** + * Pass the selected date to parent when 'OK' is clicked + */ + passSavedDateDateToParent() { + this.passDateToParent(this.state.currentDate); + } + passDateToParent(date: Date) { + if (typeof this.props.dateReceiver === "function") + this.props.dateReceiver(date); + this.closeDatePicker(); + } + + componentDidUpdate() { + if (this.state.selectYearMode) { + document.getElementsByClassName("selected")[0].scrollIntoView(); // works in every browser incl. IE, replace with scrollIntoViewIfNeeded when browsers support it + } + } + + constructor() { + super(); + + this.closeDatePicker = this.closeDatePicker.bind(this); + this.dayClicked = this.dayClicked.bind(this); + this.displayNextMonth = this.displayNextMonth.bind(this); + this.displayPrevMonth = this.displayPrevMonth.bind(this); + this.getDaysByMonth = this.getDaysByMonth.bind(this); + this.changeDisplayedYear = this.changeDisplayedYear.bind(this); + this.passDateToParent = this.passDateToParent.bind(this); + this.toggleYearSelector = this.toggleYearSelector.bind(this); + this.displaySelectedMonth = this.displaySelectedMonth.bind(this); + + this.state = { + currentDate: now, + displayedMonth: now.getMonth(), + displayedYear: now.getFullYear(), + selectYearMode: false, + }; + } + + render() { + const { currentDate, displayedMonth, displayedYear, selectYearMode } = + this.state; + + return ( + <div> + <div class={`datePicker ${this.props.opened && "datePicker--opened"}`}> + <div class="datePicker--titles"> + <h3 + style={{ + color: selectYearMode + ? "rgba(255,255,255,.87)" + : "rgba(255,255,255,.57)", + }} + onClick={this.toggleYearSelector} + > + {currentDate.getFullYear()} + </h3> + <h2 + style={{ + color: !selectYearMode + ? "rgba(255,255,255,.87)" + : "rgba(255,255,255,.57)", + }} + onClick={this.displaySelectedMonth} + > + {dayArr[currentDate.getDay()]},{" "} + {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()} + </h2> + </div> + + {!selectYearMode && ( + <nav> + <span onClick={this.displayPrevMonth} class="icon"> + <i + style={{ transform: "rotate(180deg)" }} + class="mdi mdi-forward" + /> + </span> + <h4> + {monthArrShortFull[displayedMonth]} {displayedYear} + </h4> + <span onClick={this.displayNextMonth} class="icon"> + <i class="mdi mdi-forward" /> + </span> + </nav> + )} + + <div class="datePicker--scroll"> + {!selectYearMode && ( + <div class="datePicker--calendar"> + <div class="datePicker--dayNames"> + {["S", "M", "T", "W", "T", "F", "S"].map((day, i) => ( + <span key={i}>{day}</span> + ))} + </div> + + <div onClick={this.dayClicked} class="datePicker--days"> + {/* + Loop through the calendar object returned by getDaysByMonth(). + */} + + {this.getDaysByMonth( + this.state.displayedMonth, + this.state.displayedYear, + ).map((day) => { + let selected = false; + + if (currentDate && day.date) + selected = + currentDate.toLocaleDateString() === + day.date.toLocaleDateString(); + + return ( + <span + key={day.day} + class={ + (day.today ? "datePicker--today " : "") + + (selected ? "datePicker--selected" : "") + } + disabled={!day.date} + data-value={day.date} + > + {day.day} + </span> + ); + })} + </div> + </div> + )} + + {selectYearMode && ( + <div class="datePicker--selectYear"> + {yearArr.map((year) => ( + <span + key={year} + class={year === displayedYear ? "selected" : ""} + onClick={this.changeDisplayedYear} + > + {year} + </span> + ))} + </div> + )} + </div> + </div> + + <div + class="datePicker--background" + onClick={this.closeDatePicker} + style={{ + display: this.props.opened ? "block" : "none", + }} + /> + </div> + ); + } +} + +const monthArrShortFull = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +const monthArrShort = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +const dayArr = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +const now = new Date(); + +const yearArr: number[] = []; + +for (let i = 2010; i <= now.getFullYear() + 10; i++) { + yearArr.push(i); +} diff --git a/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx new file mode 100644 index 000000000..8f74d55ac --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.stories.tsx @@ -0,0 +1,55 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, FunctionalComponent } from "preact"; +import { useState } from "preact/hooks"; +import { DurationPicker as TestedComponent } from "./DurationPicker.js"; + +export default { + title: "Components/Picker/Duration", + component: TestedComponent, + argTypes: { + onCreate: { action: "onCreate" }, + goBack: { action: "goBack" }, + }, +}; + +function createExample<Props>( + Component: FunctionalComponent<Props>, + props: Partial<Props>, +) { + const r = (args: any) => <Component {...args} />; + r.args = props; + return r; +} + +export const Example = createExample(TestedComponent, { + days: true, + minutes: true, + hours: true, + seconds: true, + value: 10000000, +}); + +export const WithState = () => { + const [v, s] = useState<number>(1000000); + return <TestedComponent value={v} onChange={s} days minutes hours seconds />; +}; diff --git a/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx new file mode 100644 index 000000000..ba003cce5 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/picker/DurationPicker.tsx @@ -0,0 +1,211 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import "../../scss/DurationPicker.scss"; + +export interface Props { + hours?: boolean; + minutes?: boolean; + seconds?: boolean; + days?: boolean; + onChange: (value: number) => void; + value: number; +} + +// inspiration taken from https://github.com/flurmbo/react-duration-picker +export function DurationPicker({ + days, + hours, + minutes, + seconds, + onChange, + value, +}: Props): VNode { + const ss = 1000; + const ms = ss * 60; + const hs = ms * 60; + const ds = hs * 24; + const { i18n } = useTranslationContext(); + + return ( + <div class="rdp-picker"> + {days && ( + <DurationColumn + unit={i18n.str`days`} + max={99} + value={Math.floor(value / ds)} + onDecrease={value >= ds ? () => onChange(value - ds) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined} + onChange={(diff) => onChange(value + diff * ds)} + /> + )} + {hours && ( + <DurationColumn + unit={i18n.str`hours`} + max={23} + min={1} + value={Math.floor(value / hs) % 24} + onDecrease={value >= hs ? () => onChange(value - hs) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined} + onChange={(diff) => onChange(value + diff * hs)} + /> + )} + {minutes && ( + <DurationColumn + unit={i18n.str`minutes`} + max={59} + min={1} + value={Math.floor(value / ms) % 60} + onDecrease={value >= ms ? () => onChange(value - ms) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined} + onChange={(diff) => onChange(value + diff * ms)} + /> + )} + {seconds && ( + <DurationColumn + unit={i18n.str`seconds`} + max={59} + value={Math.floor(value / ss) % 60} + onDecrease={value >= ss ? () => onChange(value - ss) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined} + onChange={(diff) => onChange(value + diff * ss)} + /> + )} + </div> + ); +} + +interface ColProps { + unit: string; + min?: number; + max: number; + value: number; + onIncrease?: () => void; + onDecrease?: () => void; + onChange?: (diff: number) => void; +} + +function InputNumber({ + initial, + onChange, +}: { + initial: number; + onChange: (n: number) => void; +}) { + const [value, handler] = useState<{ v: string }>({ + v: toTwoDigitString(initial), + }); + + return ( + <input + value={value.v} + onBlur={(e) => onChange(parseInt(value.v, 10))} + onInput={(e) => { + e.preventDefault(); + const n = Number.parseInt(e.currentTarget.value, 10); + if (isNaN(n)) return handler({ v: toTwoDigitString(initial) }); + return handler({ v: toTwoDigitString(n) }); + }} + style={{ + width: 50, + border: "none", + fontSize: "inherit", + background: "inherit", + }} + /> + ); +} + +function DurationColumn({ + unit, + min = 0, + max, + value, + onIncrease, + onDecrease, + onChange, +}: ColProps): VNode { + const cellHeight = 35; + return ( + <div class="rdp-column-container"> + <div class="rdp-masked-div"> + <hr class="rdp-reticule" style={{ top: cellHeight * 2 - 1 }} /> + <hr class="rdp-reticule" style={{ top: cellHeight * 3 - 1 }} /> + + <div class="rdp-column" style={{ top: 0 }}> + <div class="rdp-cell" key={value - 2}> + {onDecrease && ( + <button + style={{ width: "100%", textAlign: "center", margin: 5 }} + onClick={onDecrease} + > + <span class="icon"> + <i class="mdi mdi-chevron-up" /> + </span> + </button> + )} + </div> + <div class="rdp-cell" key={value - 1}> + {value > min ? toTwoDigitString(value - 1) : ""} + </div> + <div class="rdp-cell rdp-center" key={value}> + {onChange ? ( + <InputNumber + initial={value} + onChange={(n) => onChange(n - value)} + /> + ) : ( + toTwoDigitString(value) + )} + <div>{unit}</div> + </div> + + <div class="rdp-cell" key={value + 1}> + {value < max ? toTwoDigitString(value + 1) : ""} + </div> + + <div class="rdp-cell" key={value + 2}> + {onIncrease && ( + <button + style={{ width: "100%", textAlign: "center", margin: 5 }} + onClick={onIncrease} + > + <span class="icon"> + <i class="mdi mdi-chevron-down" /> + </span> + </button> + )} + </div> + </div> + </div> + </div> + ); +} + +function toTwoDigitString(n: number) { + if (n < 10) { + return `0${n}`; + } + return `${n}`; +} diff --git a/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx new file mode 100644 index 000000000..2d5a54cde --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx @@ -0,0 +1,62 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode, FunctionalComponent } from "preact"; +import { InventoryProductForm as TestedComponent } from "./InventoryProductForm.js"; + +export default { + title: "Components/Product/Add", + component: TestedComponent, + argTypes: { + onAddProduct: { action: "onAddProduct" }, + }, +}; + +function createExample<Props>( + Component: FunctionalComponent<Props>, + props: Partial<Props>, +) { + const r = (args: any) => <Component {...args} />; + r.args = props; + return r; +} + +export const WithASimpleList = createExample(TestedComponent, { + inventory: [ + { + id: "this id", + description: "this is the description", + } as any, + ], +}); + +export const WithAProductSelected = createExample(TestedComponent, { + inventory: [], + currentProducts: { + thisid: { + quantity: 1, + product: { + id: "asd", + description: "asdsadsad", + } as any, + }, + }, +}); diff --git a/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx new file mode 100644 index 000000000..377d9c1ba --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/product/InventoryProductForm.tsx @@ -0,0 +1,127 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { MerchantBackend, WithId } from "../../declaration.js"; +import { ProductMap } from "../../paths/instance/orders/create/CreatePage.js"; +import { FormErrors, FormProvider } from "../form/FormProvider.js"; +import { InputNumber } from "../form/InputNumber.js"; +import { InputSearchOnList } from "../form/InputSearchOnList.js"; + +type Form = { + product: MerchantBackend.Products.ProductDetail & WithId; + quantity: number; +}; + +interface Props { + currentProducts: ProductMap; + onAddProduct: ( + product: MerchantBackend.Products.ProductDetail & WithId, + quantity: number, + ) => void; + inventory: (MerchantBackend.Products.ProductDetail & WithId)[]; +} + +export function InventoryProductForm({ + currentProducts, + onAddProduct, + inventory, +}: Props): VNode { + const initialState = { quantity: 1 }; + const [state, setState] = useState<Partial<Form>>(initialState); + const [errors, setErrors] = useState<FormErrors<Form>>({}); + + const { i18n } = useTranslationContext(); + + const productWithInfiniteStock = + state.product && state.product.total_stock === -1; + + const submit = (): void => { + if (!state.product) { + setErrors({ + product: i18n.str`You must enter a valid product identifier.`, + }); + return; + } + if (productWithInfiniteStock) { + onAddProduct(state.product, 1); + } else { + if (!state.quantity || state.quantity <= 0) { + setErrors({ quantity: i18n.str`Quantity must be greater than 0!` }); + return; + } + const currentStock = + state.product.total_stock - + state.product.total_lost - + state.product.total_sold; + const p = currentProducts[state.product.id]; + if (p) { + if (state.quantity + p.quantity > currentStock) { + const left = currentStock - p.quantity; + setErrors({ + quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`, + }); + return; + } + onAddProduct(state.product, state.quantity + p.quantity); + } else { + if (state.quantity > currentStock) { + const left = currentStock; + setErrors({ + quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`, + }); + return; + } + onAddProduct(state.product, state.quantity); + } + } + + setState(initialState); + }; + + return ( + <FormProvider<Form> errors={errors} object={state} valueHandler={setState}> + <InputSearchOnList + label={i18n.str`Search product`} + selected={state.product} + onChange={(p) => setState((v) => ({ ...v, product: p }))} + list={inventory} + withImage + /> + {state.product && ( + <div class="columns mt-5"> + <div class="column is-two-thirds"> + {!productWithInfiniteStock && ( + <InputNumber<Form> + name="quantity" + label={i18n.str`Quantity`} + tooltip={i18n.str`how many products will be added`} + /> + )} + </div> + <div class="column"> + <div class="buttons is-right"> + <button class="button is-success" onClick={submit}> + <i18n.Translate>Add from inventory</i18n.Translate> + </button> + </div> + </div> + </div> + )} + </FormProvider> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx b/packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx new file mode 100644 index 000000000..c6d280f94 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/product/NonInventoryProductForm.tsx @@ -0,0 +1,215 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import * as yup from "yup"; +import { MerchantBackend } from "../../declaration.js"; +import { useListener } from "../../hooks/listener.js"; +import { NonInventoryProductSchema as schema } from "../../schemas/index.js"; +import { FormErrors, FormProvider } from "../form/FormProvider.js"; +import { Input } from "../form/Input.js"; +import { InputCurrency } from "../form/InputCurrency.js"; +import { InputImage } from "../form/InputImage.js"; +import { InputNumber } from "../form/InputNumber.js"; +import { InputTaxes } from "../form/InputTaxes.js"; + +type Entity = MerchantBackend.Product; + +interface Props { + onAddProduct: (p: Entity) => Promise<void>; + productToEdit?: Entity; +} +export function NonInventoryProductFrom({ + productToEdit, + onAddProduct, +}: Props): VNode { + const [showCreateProduct, setShowCreateProduct] = useState(false); + + const isEditing = !!productToEdit; + + useEffect(() => { + setShowCreateProduct(isEditing); + }, [isEditing]); + + const [submitForm, addFormSubmitter] = useListener< + Partial<MerchantBackend.Product> | undefined + >((result) => { + if (result) { + setShowCreateProduct(false); + return onAddProduct({ + quantity: result.quantity || 0, + taxes: result.taxes || [], + description: result.description || "", + image: result.image || "", + price: result.price || "", + unit: result.unit || "", + }); + } + return Promise.resolve(); + }); + + const { i18n } = useTranslationContext(); + + return ( + <Fragment> + <div class="buttons"> + <button + class="button is-success" + data-tooltip={i18n.str`describe and add a product that is not in the inventory list`} + onClick={() => setShowCreateProduct(true)} + > + <i18n.Translate>Add custom product</i18n.Translate> + </button> + </div> + {showCreateProduct && ( + <div class="modal is-active"> + <div + class="modal-background " + onClick={() => setShowCreateProduct(false)} + /> + <div class="modal-card"> + <header class="modal-card-head"> + <p class="modal-card-title">{i18n.str`Complete information of the product`}</p> + <button + class="delete " + aria-label="close" + onClick={() => setShowCreateProduct(false)} + /> + </header> + <section class="modal-card-body"> + <ProductForm + initial={productToEdit} + onSubscribe={addFormSubmitter} + /> + </section> + <footer class="modal-card-foot"> + <div class="buttons is-right" style={{ width: "100%" }}> + <button + class="button " + onClick={() => setShowCreateProduct(false)} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + <button + class="button is-info " + disabled={!submitForm} + onClick={submitForm} + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> + </footer> + </div> + <button + class="modal-close is-large " + aria-label="close" + onClick={() => setShowCreateProduct(false)} + /> + </div> + )} + </Fragment> + ); +} + +interface ProductProps { + onSubscribe: (c?: () => Entity | undefined) => void; + initial?: Partial<Entity>; +} + +interface NonInventoryProduct { + quantity: number; + description: string; + unit: string; + price: string; + image: string; + taxes: MerchantBackend.Tax[]; +} + +export function ProductForm({ onSubscribe, initial }: ProductProps): VNode { + const [value, valueHandler] = useState<Partial<NonInventoryProduct>>({ + taxes: [], + ...initial, + }); + let errors: FormErrors<Entity> = {}; + try { + schema.validateSync(value, { abortEarly: false }); + } catch (err) { + if (err instanceof yup.ValidationError) { + const yupErrors = err.inner as yup.ValidationError[]; + errors = yupErrors.reduce( + (prev, cur) => + !cur.path ? prev : { ...prev, [cur.path]: cur.message }, + {}, + ); + } + } + + const submit = useCallback((): Entity | undefined => { + return value as MerchantBackend.Product; + }, [value]); + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + useEffect(() => { + onSubscribe(hasErrors ? undefined : submit); + }, [submit, hasErrors]); + + const { i18n } = useTranslationContext(); + + return ( + <div> + <FormProvider<NonInventoryProduct> + name="product" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <InputImage<NonInventoryProduct> + name="image" + label={i18n.str`Image`} + tooltip={i18n.str`photo of the product`} + /> + <Input<NonInventoryProduct> + name="description" + inputType="multiline" + label={i18n.str`Description`} + tooltip={i18n.str`full product description`} + /> + <Input<NonInventoryProduct> + name="unit" + label={i18n.str`Unit`} + tooltip={i18n.str`name of the product unit`} + /> + <InputCurrency<NonInventoryProduct> + name="price" + label={i18n.str`Price`} + tooltip={i18n.str`amount in the current currency`} + /> + + <InputNumber<NonInventoryProduct> + name="quantity" + label={i18n.str`Quantity`} + tooltip={i18n.str`how many products will be added`} + /> + + <InputTaxes<NonInventoryProduct> name="taxes" label={i18n.str`Taxes`} /> + </FormProvider> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx b/packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx new file mode 100644 index 000000000..e91e8c876 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/product/ProductForm.tsx @@ -0,0 +1,178 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h } from "preact"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import * as yup from "yup"; +import { useBackendContext } from "../../context/backend.js"; +import { MerchantBackend } from "../../declaration.js"; +import { + ProductCreateSchema as createSchema, + ProductUpdateSchema as updateSchema, +} from "../../schemas/index.js"; +import { FormErrors, FormProvider } from "../form/FormProvider.js"; +import { Input } from "../form/Input.js"; +import { InputCurrency } from "../form/InputCurrency.js"; +import { InputImage } from "../form/InputImage.js"; +import { InputNumber } from "../form/InputNumber.js"; +import { InputStock, Stock } from "../form/InputStock.js"; +import { InputTaxes } from "../form/InputTaxes.js"; +import { InputWithAddon } from "../form/InputWithAddon.js"; + +type Entity = MerchantBackend.Products.ProductDetail & { product_id: string }; + +interface Props { + onSubscribe: (c?: () => Entity | undefined) => void; + initial?: Partial<Entity>; + alreadyExist?: boolean; +} + +export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { + const [value, valueHandler] = useState<Partial<Entity & { stock: Stock }>>({ + address: {}, + description_i18n: {}, + taxes: [], + next_restock: { t_s: "never" }, + price: ":0", + ...initial, + stock: + !initial || initial.total_stock === -1 + ? undefined + : { + current: initial.total_stock || 0, + lost: initial.total_lost || 0, + sold: initial.total_sold || 0, + address: initial.address, + nextRestock: initial.next_restock, + }, + }); + let errors: FormErrors<Entity> = {}; + + try { + (alreadyExist ? updateSchema : createSchema).validateSync(value, { + abortEarly: false, + }); + } catch (err) { + if (err instanceof yup.ValidationError) { + const yupErrors = err.inner as yup.ValidationError[]; + errors = yupErrors.reduce( + (prev, cur) => + !cur.path ? prev : { ...prev, [cur.path]: cur.message }, + {}, + ); + } + } + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submit = useCallback((): Entity | undefined => { + const stock: Stock = (value as any).stock; + + if (!stock) { + value.total_stock = -1; + } else { + value.total_stock = stock.current; + value.total_lost = stock.lost; + value.next_restock = + stock.nextRestock instanceof Date + ? { t_s: stock.nextRestock.getTime() / 1000 } + : stock.nextRestock; + value.address = stock.address; + } + delete (value as any).stock; + + if (typeof value.minimum_age !== "undefined" && value.minimum_age < 1) { + delete value.minimum_age; + } + + return value as MerchantBackend.Products.ProductDetail & { + product_id: string; + }; + }, [value]); + + useEffect(() => { + onSubscribe(hasErrors ? undefined : submit); + }, [submit, hasErrors]); + + const { url: backendURL } = useBackendContext() + const { i18n } = useTranslationContext(); + + return ( + <div> + <FormProvider<Entity> + name="product" + errors={errors} + object={value} + valueHandler={valueHandler} + > + {alreadyExist ? undefined : ( + <InputWithAddon<Entity> + name="product_id" + addonBefore={`${backendURL}/product/`} + label={i18n.str`ID`} + tooltip={i18n.str`product identification to use in URLs (for internal use only)`} + /> + )} + <InputImage<Entity> + name="image" + label={i18n.str`Image`} + tooltip={i18n.str`illustration of the product for customers`} + /> + <Input<Entity> + name="description" + inputType="multiline" + label={i18n.str`Description`} + tooltip={i18n.str`product description for customers`} + /> + <InputNumber<Entity> + name="minimum_age" + label={i18n.str`Age restriction`} + tooltip={i18n.str`is this product restricted for customer below certain age?`} + help={i18n.str`minimum age of the buyer`} + /> + <Input<Entity> + name="unit" + label={i18n.str`Unit name`} + tooltip={i18n.str`unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers`} + help={i18n.str`exajmple: kg, items or liters`} + /> + <InputCurrency<Entity> + name="price" + label={i18n.str`Price per unit`} + tooltip={i18n.str`sale price for customers, including taxes, for above units of the product`} + /> + <InputStock + name="stock" + label={i18n.str`Stock`} + alreadyExist={alreadyExist} + tooltip={i18n.str`inventory for products with finite supply (for internal use only)`} + /> + <InputTaxes<Entity> + name="taxes" + label={i18n.str`Taxes`} + tooltip={i18n.str`taxes included in the product price, exposed to customers`} + /> + </FormProvider> + </div> + ); +} diff --git a/packages/auditor-backoffice-ui/src/components/product/ProductList.tsx b/packages/auditor-backoffice-ui/src/components/product/ProductList.tsx new file mode 100644 index 000000000..25751dd96 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/components/product/ProductList.tsx @@ -0,0 +1,106 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { Amounts } from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import emptyImage from "../../assets/empty.png"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { MerchantBackend } from "../../declaration.js"; + +interface Props { + list: MerchantBackend.Product[]; + actions?: { + name: string; + tooltip: string; + handler: (d: MerchantBackend.Product, index: number) => void; + }[]; +} +export function ProductList({ list, actions = [] }: Props): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>image</i18n.Translate> + </th> + <th> + <i18n.Translate>description</i18n.Translate> + </th> + <th> + <i18n.Translate>quantity</i18n.Translate> + </th> + <th> + <i18n.Translate>unit price</i18n.Translate> + </th> + <th> + <i18n.Translate>total price</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {list.map((entry, index) => { + const unitPrice = !entry.price ? "0" : entry.price; + const totalPrice = !entry.price + ? "0" + : Amounts.stringify( + Amounts.mult( + Amounts.parseOrThrow(entry.price), + entry.quantity, + ).amount, + ); + + return ( + <tr key={index}> + <td> + <img + style={{ height: 32, width: 32 }} + src={entry.image ? entry.image : emptyImage} + /> + </td> + <td>{entry.description}</td> + <td> + {entry.quantity === 0 + ? "--" + : `${entry.quantity} ${entry.unit}`} + </td> + <td>{unitPrice}</td> + <td>{totalPrice}</td> + <td class="is-actions-cell right-sticky"> + {actions.map((a, i) => { + return ( + <div key={i} class="buttons is-right"> + <button + class="button is-small is-danger has-tooltip-left" + data-tooltip={a.tooltip} + type="button" + onClick={() => a.handler(entry, index)} + > + {a.name} + </button> + </div> + ); + })} + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + ); +} |