taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 0ef0be7eaa82a5fa9425aede80dca0967d912049
parent 24c6d46d34de6b42fe6434d0a5ab4f405c86f27d
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu,  1 Aug 2024 12:17:07 -0300

wip #8839 - missing product create/update

Diffstat:
Apackages/merchant-backoffice-ui/error.db | 22++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/Application.tsx | 24++++++++++++++++++++++--
Mpackages/merchant-backoffice-ui/src/Routing.tsx | 46+++++++++++++++++++++++++++++++++++++++++-----
Mpackages/merchant-backoffice-ui/src/components/form/InputArray.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/merchant-backoffice-ui/src/components/form/InputPayto.tsx | 1-
Mpackages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx | 2+-
Mpackages/merchant-backoffice-ui/src/components/menu/SideBar.tsx | 10++++++++++
Mpackages/merchant-backoffice-ui/src/components/menu/index.tsx | 6++++++
Mpackages/merchant-backoffice-ui/src/components/product/ProductForm.tsx | 34+++++++++++++++++++++++++++-------
Apackages/merchant-backoffice-ui/src/hooks/category.ts | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/categories/create/Create.stories.tsx | 28++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/categories/create/CreatePage.tsx | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/categories/create/index.tsx | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/categories/list/Table.tsx | 205+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/categories/list/index.tsx | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/categories/update/Update.stories.tsx | 32++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/categories/update/UpdatePage.tsx | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/categories/update/index.tsx | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx | 2+-
19 files changed, 1028 insertions(+), 45 deletions(-)

diff --git a/packages/merchant-backoffice-ui/error.db b/packages/merchant-backoffice-ui/error.db @@ -0,0 +1,22 @@ + JOIN bank_account_transactions AS txs + ON bank_transaction=txs.bank_transaction_id + WHERE + bank_account_id=$1 AND + bank_transaction_id > $2 + ORDER BY bank_transaction_id ASC + LIMIT $3 + +2024-08-01 09:45:45.981 -03 [sebasjm] sebasjm@bank DETAIL: parameters: $1 = '5', $2 = '0', $3 = '1024' +2024-08-01 09:45:45.981 -03 [sebasjm] sebasjm@bank LOG: duration: 0.011 ms +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant DEBUG: bind <unnamed> to lookup_kyc_status +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant LOG: duration: 0.057 ms bind lookup_kyc_status: SELECT h_wire,exchange_kyc_serial,payto_uri,exchange_url,kyc_timestamp,kyc_ok FROM merchant_instances JOIN merchant_accounts USING (merchant_serial) JOIN merchant_kyc USING (account_serial) WHERE merchant_instances.merchant_id=$1 +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant DETAIL: parameters: $1 = 'default' +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant LOG: execute lookup_kyc_status: SELECT h_wire,exchange_kyc_serial,payto_uri,exchange_url,kyc_timestamp,kyc_ok FROM merchant_instances JOIN merchant_accounts USING (merchant_serial) JOIN merchant_kyc USING (account_serial) WHERE merchant_instances.merchant_id=$1 +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant DETAIL: parameters: $1 = 'default' +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant LOG: duration: 0.036 ms +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant DEBUG: bind <unnamed> to select_accounts +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant LOG: duration: 0.028 ms bind select_accounts: SELECT h_wire,salt,payto_uri,credit_facade_url,credit_facade_credentials,active FROM merchant_accounts WHERE merchant_serial= (SELECT merchant_serial FROM merchant_instances WHERE merchant_id=$1); +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant DETAIL: parameters: $1 = 'default' +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant LOG: execute select_accounts: SELECT h_wire,salt,payto_uri,credit_facade_url,credit_facade_credentials,active FROM merchant_accounts WHERE merchant_serial= (SELECT merchant_serial FROM merchant_instances WHERE merchant_id=$1); +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant DETAIL: parameters: $1 = 'default' +2024-08-01 09:45:47.237 -03 [sebasjm] sebasjm@merchant LOG: duration: 0.019 ms diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx @@ -83,6 +83,7 @@ import { buildDefaultBackendBaseURL, fetchSettings, } from "./settings.js"; +import { revalidateInstanceCategories } from "./hooks/category.js"; const WITH_LOCAL_STORAGE_CACHE = false; export function Application(): VNode { @@ -280,19 +281,38 @@ const swrCacheEvictor = new (class await Promise.all([revalidateInstanceBankAccounts()]); return; } + case TalerMerchantInstanceCacheEviction.CREATE_CATEGORY: { + await Promise.all([revalidateInstanceCategories()]); + return; + } + case TalerMerchantInstanceCacheEviction.UPDATE_CATEGORY: { + await Promise.all([revalidateInstanceCategories()]); + return; + } + case TalerMerchantInstanceCacheEviction.DELETE_CATEGORY: { + await Promise.all([revalidateInstanceCategories()]); + return; + } case TalerMerchantInstanceCacheEviction.CREATE_PRODUCT: { - await Promise.all([revalidateInstanceProducts()]); + await Promise.all([ + revalidateInstanceProducts(), + revalidateInstanceCategories(), + ]); return; } case TalerMerchantInstanceCacheEviction.UPDATE_PRODUCT: { await Promise.all([ revalidateProductDetails(), revalidateInstanceProducts(), + revalidateInstanceCategories(), ]); return; } case TalerMerchantInstanceCacheEviction.DELETE_PRODUCT: { - await Promise.all([revalidateInstanceProducts()]); + await Promise.all([ + revalidateInstanceProducts(), + revalidateInstanceCategories(), + ]); return; } case TalerMerchantInstanceCacheEviction.CREATE_TRANSFER: { diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -75,6 +75,9 @@ import WebhookUpdatePage from "./paths/instance/webhooks/update/index.js"; import { LoginPage } from "./paths/login/index.js"; import { Settings } from "./paths/settings/index.js"; import { Notification } from "./utils/types.js"; +import ListCategories from "./paths/instance/categories/list/index.js"; +import CreateCategory from "./paths/instance/categories/create/index.js"; +import UpdateCategory from "./paths/instance/categories/update/index.js"; export enum InstancePaths { error = "/error", @@ -85,6 +88,10 @@ export enum InstancePaths { bank_update = "/bank/:bid/update", bank_new = "/bank/new", + category_list = "/category", + category_update = "/category/:cid/update", + category_new = "/category/new", + inventory_list = "/inventory", inventory_update = "/inventory/:pid/update", inventory_new = "/inventory/new", @@ -299,6 +306,39 @@ export function Routing(_p: Props): VNode { }} /> {/** + * Category pages + */} + <Route + path={InstancePaths.category_list} + component={ListCategories} + onCreate={() => { + route(InstancePaths.category_new); + }} + onSelect={(id: string) => { + route(InstancePaths.category_update.replace(":cid", id)); + }} + /> + <Route + path={InstancePaths.category_update} + component={UpdateCategory} + onConfirm={() => { + route(InstancePaths.category_list); + }} + onBack={() => { + route(InstancePaths.category_list); + }} + /> + <Route + path={InstancePaths.category_new} + component={CreateCategory} + onConfirm={() => { + route(InstancePaths.category_list); + }} + onBack={() => { + route(InstancePaths.category_list); + }} + /> + {/** * Inventory pages */} <Route @@ -593,13 +633,9 @@ function AdminInstanceUpdatePage({ id, ...rest }: { id: string } & InstanceUpdatePageProps): VNode { - return ( <Fragment> - <InstanceAdminUpdatePage - {...rest} - instanceId={id} - /> + <InstanceAdminUpdatePage {...rest} instanceId={id} /> </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx b/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx @@ -22,15 +22,17 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { InputProps, useField } from "./useField.js"; +import { DropdownList } from "./InputSearchOnList.js"; export interface Props<T> extends InputProps<T> { isValid?: (e: any) => boolean; + getSuggestion?: (e: any) => { id: string; description: string }[]; addonBefore?: string; toStr?: (v?: any) => string; fromStr?: (s: string) => any; } -const defaultToString = (f?: any): string => f || ""; +const defaultToString = (f?: any): string => (f ? String(f) : ""); const defaultFromString = (v: string): any => v as any; export function InputArray<T>({ @@ -41,17 +43,19 @@ export function InputArray<T>({ label, help, addonBefore, - isValid = () => true, + getSuggestion, 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 error = formError; - const array: T[keyof T][] = (value ? value! : []); + const array: T[keyof T][] = value ? value! : []; const [currentValue, setCurrentValue] = useState(""); + const [suggestions, setSuggestions] = useState< + { id: string; description: string }[] + >([]); const { i18n } = useTranslationContext(); return ( @@ -83,7 +87,12 @@ export function InputArray<T>({ disabled={readonly} name={String(name)} value={currentValue} - onChange={(e): void => setCurrentValue(e.currentTarget.value)} + onChange={(e): void => { + setCurrentValue(e.currentTarget.value); + if (getSuggestion) { + setSuggestions(getSuggestion(e.currentTarget.value)); + } + }} /> {required && ( <span class="icon has-text-danger is-right"> @@ -91,27 +100,22 @@ export function InputArray<T>({ </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 T[keyof T]); - setCurrentValue(""); - }} - data-tooltip={i18n.str`Add element to the list`} - > - <i18n.Translate>Add</i18n.Translate> - </button> - </p> + {getSuggestion ? undefined : ( + <p class="control"> + <button + class="button is-info has-tooltip-left" + disabled={!currentValue} + onClick={(): void => { + const v = fromStr(currentValue); + onChange([v, ...array] as T[keyof T]); + 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>} @@ -121,7 +125,7 @@ export function InputArray<T>({ class="tag is-medium is-info mb-0" style={{ maxWidth: "90%" }} > - {String(v)} + {toStr(v)} </span> <a class="tag is-medium is-danger is-delete mb-0" @@ -132,6 +136,20 @@ export function InputArray<T>({ /> </div> ))} + {suggestions.length > 0 ? ( + <div> + <DropdownList + name={currentValue} + list={suggestions} + onSelect={(p): void => { + setCurrentValue(""); + onChange([p, ...array] as T[keyof T]); + setSuggestions([]); + }} + withImage={false} + /> + </div> + ) : undefined} </div> </div> </div> diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx @@ -44,7 +44,6 @@ export function InputPayto<T>({ 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/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSearchOnList.tsx @@ -133,7 +133,7 @@ interface DropdownListProps<T extends Entity> { withImage: boolean; } -function DropdownList<T extends Entity>({ name, onSelect, list, withImage }: DropdownListProps<T>) { +export function DropdownList<T extends Entity>({ name, onSelect, list, withImage }: DropdownListProps<T>) { const { i18n } = useTranslationContext(); if (!name) { /* FIXME diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -101,6 +101,16 @@ export function Sidebar({ mobile }: Props): VNode { </a> </li> <li> + <a href={"/category"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-label-outline" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Categories</i18n.Translate> + </span> + </a> + </li> + <li> <a href={"/transfers"} class="has-icon"> <span class="icon"> <i class="mdi mdi-arrow-left-right" /> diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx @@ -44,6 +44,12 @@ function getInstanceTitle(path: string, id: string): string { return `${id}: New product`; case InstancePaths.inventory_update: return `${id}: Update product`; + case InstancePaths.category_list: + return `${id}: Category`; + case InstancePaths.category_new: + return `${id}: New category`; + case InstancePaths.category_update: + return `${id}: Update category`; case InstancePaths.transfers_list: return `${id}: Transfers`; case InstancePaths.transfers_new: diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx @@ -33,6 +33,7 @@ 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"; +import { InputArray } from "../form/InputArray.js"; type Entity = TalerMerchantApi.ProductDetail & { product_id: string }; @@ -50,6 +51,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { address: {}, description_i18n: {}, taxes: [], + categories: [], next_restock: { t_s: "never" }, price: ":0" as AmountString, ...initial, @@ -131,19 +133,19 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { name="product_id" addonBefore={new URL("product/", state.backendUrl.href).href} label={i18n.str`ID`} - tooltip={i18n.str`Product identification to use in URLs (for internal use only)`} + 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`} + 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`} + tooltip={i18n.str`Product description for customers.`} /> <InputNumber<Entity> name="minimum_age" @@ -154,24 +156,42 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) { <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`} + tooltip={i18n.str`Unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers.`} help={i18n.str`Example: 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`} + 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)`} + 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`} + tooltip={i18n.str`Taxes included in the product price, exposed to customers.`} + /> + <InputArray<Entity> + name="categories" + label={i18n.str`Categories`} + getSuggestion={() => { + return [ + { + description: "brown beer", + id: "bb", + }, + { + description: "yellow beer", + id: "yb", + }, + ]; + }} + toStr={(v) => v.description} + tooltip={i18n.str`Categories where this product will be listed on.`} /> </FormProvider> </div> diff --git a/packages/merchant-backoffice-ui/src/hooks/category.ts b/packages/merchant-backoffice-ui/src/hooks/category.ts @@ -0,0 +1,78 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import { AccessToken, TalerHttpError, TalerMerchantManagementResultByMethod } from "@gnu-taler/taler-util"; +import _useSWR, { SWRHook, mutate } from "swr"; +import { useSessionContext } from "../context/session.js"; +const useSWR = _useSWR as unknown as SWRHook; + +export function revalidateInstanceCategories() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "listCategories", + undefined, + { revalidate: true }, + ); +} +export function useInstanceCategories() { + const { state, lib } = useSessionContext(); + + // const [offset, setOffset] = useState<string | undefined>(); + + async function fetcher([token, _bid]: [AccessToken, string]) { + return await lib.instance.listCategories(token, { + // limit: PAGINATED_LIST_REQUEST, + // offset: bid, + // order: "dec", + }); + } + + const { data, error } = useSWR< + TalerMerchantManagementResultByMethod<"listCategories">, + TalerHttpError + >([state.token, "offset", "listCategories"], fetcher); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + // return buildPaginatedResult(data.body.otp_devices, offset, setOffset, (d) => d.otp_device_id) + return data; +} + +export function revalidateCategoryDetails() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getCategoryDetails", + undefined, + { revalidate: true }, + ); +} +export function useCategoryDetails(deviceId: string) { + const { state, lib } = useSessionContext(); + + async function fetcher([dId, token]: [string, AccessToken]) { + return await lib.instance.getCategoryDetails(token, dId); + } + + const { data, error } = useSWR< + TalerMerchantManagementResultByMethod<"getCategoryDetails">, + TalerHttpError + >([deviceId, state.token, "getCategoryDetails"], fetcher); + + if (data) return data; + if (error) return error; + return undefined; +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/categories/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/categories/create/Create.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode, FunctionalComponent } from "preact"; +import { CreatePage as TestedComponent } from "./CreatePage.js"; + +export default { + title: "Pages/OtpDevices/Create", + component: TestedComponent, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/categories/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/categories/create/CreatePage.tsx @@ -0,0 +1,105 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + TalerMerchantApi +} from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; + +type Entity = TalerMerchantApi.CategoryCreateRequest; + +interface Props { + onCreate: (d: Entity) => Promise<void>; + onBack?: () => void; +} + +export function CreatePage({ onCreate, onBack }: Props): VNode { + const { i18n } = useTranslationContext(); + + const [state, setState] = useState<Partial<Entity>>({name_i18n: {}}); + + const errors = undefinedIfEmpty<FormErrors<Entity>>({ + name: !state.name + ? i18n.str`Required` + : !/[a-zA-Z0-9]*/.test(state.name) + ? i18n.str`Invalid. Only characters and numbers` + : undefined, + }); + + const hasErrors = errors !== undefined; + + const submitForm = () => { + if (hasErrors) return Promise.reject(); + return onCreate(state as Entity); + }; + + return ( + <div> + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider + object={state} + valueHandler={setState} + errors={errors} + > + <Input<Entity> + name="name" + label={i18n.str`Name`} + tooltip={i18n.str`Category name`} + />{" "} + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n.str`Need to complete marked fields` + : i18n.str`Confirm operation` + } + onClick={submitForm} + > + <i18n.Translate>Confirm</i18n.Translate> + </AsyncButton> + </div> + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/categories/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/categories/create/index.tsx @@ -0,0 +1,77 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TalerMerchantApi } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { Notification } from "../../../../utils/types.js"; +import { CreatePage } from "./CreatePage.js"; + +type Entity = TalerMerchantApi.CategoryCreateRequest; +interface Props { + onBack?: () => void; + onConfirm: () => void; +} + +export default function CreateCategory({ onConfirm, onBack }: Props): VNode { + const { state, lib } = useSessionContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { i18n } = useTranslationContext(); + + return ( + <> + <NotificationCard notification={notif} /> + <CreatePage + onBack={onBack} + onCreate={async (request: Entity) => { + return lib.instance + .addCategory(state.token, request) + .then((resp) => { + if (resp.type === "ok") { + setNotif({ + message: i18n.str`Category added successfully`, + type: "SUCCESS", + }); + onConfirm() + } else { + setNotif({ + message: i18n.str`Could not add category`, + type: "ERROR", + description: resp.detail.hint, + }); + } + }) + .catch((error) => { + setNotif({ + message: i18n.str`Could not add category`, + type: "ERROR", + description: + error instanceof Error ? error.message : String(error), + }); + }); + }} + /> + </> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/categories/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/categories/list/Table.tsx @@ -0,0 +1,205 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { TalerMerchantApi } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { StateUpdater, useState } from "preact/hooks"; + +type Entity = TalerMerchantApi.CategoryListEntry; + +interface Props { + devices: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + onCreate: () => void; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; +} + +export function CardTable({ + devices, + onCreate, + onDelete, + onSelect, + onLoadMoreAfter, + onLoadMoreBefore, +}: Props): VNode { + const [rowSelection, rowSelectionHandler] = useState<string[]>([]); + + const { i18n } = useTranslationContext(); + + return ( + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-label" /> + </span> + <i18n.Translate>Categories</i18n.Translate> + </p> + <div class="card-header-icon" aria-label="more options"> + <span + class="has-tooltip-left" + data-tooltip={i18n.str`Add new devices`} + > + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small"> + <i class="mdi mdi-plus mdi-36px" /> + </span> + </button> + </span> + </div> + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {devices.length > 0 ? ( + <Table + instances={devices} + onDelete={onDelete} + onSelect={onSelect} + rowSelection={rowSelection} + rowSelectionHandler={rowSelectionHandler} + onLoadMoreAfter={onLoadMoreAfter} + onLoadMoreBefore={onLoadMoreBefore} + /> + ) : ( + <EmptyTable /> + )} + </div> + </div> + </div> + </div> + ); +} +interface TableProps { + rowSelection: string[]; + instances: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; +} + +function Table({ + instances, + onLoadMoreAfter, + onDelete, + onSelect, + onLoadMoreBefore, +}: TableProps): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="table-container"> + {onLoadMoreBefore && ( + <button + class="button is-fullwidth" + data-tooltip={i18n.str`Load more devices before the first one`} + onClick={onLoadMoreBefore} + > + <i18n.Translate>Load first page</i18n.Translate> + </button> + )} + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>ID</i18n.Translate> + </th> + <th> + <i18n.Translate>Name</i18n.Translate> + </th> + <th> + <i18n.Translate>Total products</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {instances.map((i) => { + return ( + <tr key={i.category_id}> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.category_id} + </td> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.name} + </td> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.product_count} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`Delete selected category from the database`} + onClick={() => onDelete(i)} + > + <i18n.Translate>Delete</i18n.Translate> + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + {onLoadMoreAfter && ( + <button + class="button is-fullwidth" + data-tooltip={i18n.str`Load more devices after the last one`} + onClick={onLoadMoreAfter} + > + <i18n.Translate>Load next page</i18n.Translate> + </button> + )} + </div> + ); +} + +function EmptyTable(): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="content has-text-grey has-text-centered"> + <p> + <span class="icon is-large"> + <i class="mdi mdi-magnify mdi-48px" /> + </span> + </p> + <p> + <i18n.Translate> + There is no categories yet, add more pressing the + sign + </i18n.Translate> + </p> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/categories/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/categories/list/index.tsx @@ -0,0 +1,113 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + HttpStatusCode, + TalerError, + TalerMerchantApi, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { useInstanceCategories } from "../../../../hooks/category.js"; +import { Notification } from "../../../../utils/types.js"; +import { LoginPage } from "../../../login/index.js"; +import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; +import { CardTable } from "./Table.js"; + +interface Props { + onCreate: () => void; + onSelect: (id: string) => void; +} + +export default function ListCategories({ onCreate, onSelect }: Props): VNode { + // const [position, setPosition] = useState<string | undefined>(undefined); + const { i18n } = useTranslationContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { state, lib } = useSessionContext(); + const result = useInstanceCategories(); + + if (!result) return <Loading />; + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + case HttpStatusCode.Unauthorized: { + return <LoginPage />; + } + default: { + assertUnreachable(result); + } + } + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <section class="section is-main-section"> + <CardTable + devices={result.body.categories} + onLoadMoreBefore={undefined} //result.isFirstPage ? undefined : result.loadFirst} + onLoadMoreAfter={undefined} //result.isLastPage ? undefined : result.loadNext} + onCreate={onCreate} + onSelect={(e) => { + onSelect(String(e.category_id)); + }} + onDelete={async (e: TalerMerchantApi.CategoryListEntry) => { + return lib.instance + .deleteCategory(state.token, String(e.category_id)) + .then((resp) => { + if (resp.type === "ok") { + setNotif({ + message: i18n.str`Category delete successfully`, + type: "SUCCESS", + }); + } else { + setNotif({ + message: i18n.str`Could not delete the category`, + type: "ERROR", + description: resp.detail.hint, + }); + } + }) + .catch((error) => + setNotif({ + message: i18n.str`Could not delete the category`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }), + ); + }} + /> + </section> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/categories/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/categories/update/Update.stories.tsx @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode, FunctionalComponent } from "preact"; +import { UpdatePage as TestedComponent } from "./UpdatePage.js"; + +export default { + title: "Pages/OtpDevices/Update", + component: TestedComponent, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/categories/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/categories/update/UpdatePage.tsx @@ -0,0 +1,100 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + TalerMerchantApi +} from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { FormProvider } from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { WithId } from "../../../../declaration.js"; + +type Entity = TalerMerchantApi.CategoryProductList & WithId; + +interface Props { + onUpdate: (d: Entity) => Promise<void>; + onBack?: () => void; + category: Entity; +} +export function UpdatePage({category, onUpdate, onBack }: Props): VNode { + const { i18n } = useTranslationContext(); + + const [state, setState] = useState<Partial<Entity>>(category); + + const submitForm = () => { + return onUpdate(state as Entity); + }; + + return ( + <div> + <section class="section"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span class="is-size-4"> + <i18n.Translate>Id:</i18n.Translate> + &nbsp; + <b>{category.id}</b> + </span> + </div> + </div> + </div> + </div> + </section> + <hr /> + + <section class="section is-main-section"> + <div class="columns"> + <div class="column is-four-fifths"> + <FormProvider object={state} valueHandler={setState}> + <Input<Entity> + name="name" + label={i18n.str`Name`} + tooltip={i18n.str`Name of the category`} + /> + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <AsyncButton + disabled={false} + data-tooltip={i18n.str`Confirm operation`} + onClick={submitForm} + > + <i18n.Translate>Confirm</i18n.Translate> + </AsyncButton> + </div> + </div> + </div> + </section> + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/categories/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/categories/update/index.tsx @@ -0,0 +1,114 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + HttpStatusCode, + TalerError, + assertUnreachable +} from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { useCategoryDetails } from "../../../../hooks/category.js"; +import { Notification } from "../../../../utils/types.js"; +import { LoginPage } from "../../../login/index.js"; +import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; +import { UpdatePage } from "./UpdatePage.js"; + +interface Props { + onBack?: () => void; + onConfirm: () => void; + cid: string; +} +export default function UpdateCategory({ + cid, + onConfirm, + onBack, +}: Props): VNode { + const result = useCategoryDetails(cid); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { state, lib } = useSessionContext(); + + const { i18n } = useTranslationContext(); + + if (!result) return <Loading />; + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + case HttpStatusCode.Unauthorized: { + return <LoginPage />; + } + default: { + assertUnreachable(result); + } + } + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + <UpdatePage + category={{ + ...result.body, + id: cid, + }} + onBack={onBack} + onUpdate={async (newInfo) => { + return lib.instance + .updateCategory(state.token, cid, newInfo) + .then((d) => { + if (d.type === "ok") { + onConfirm(); + } else { + switch (d.case) { + case HttpStatusCode.NotFound: { + setNotif({ + message: i18n.str`Could not update category`, + type: "ERROR", + description: i18n.str`Category id is unknown`, + }); + break; + } + } + } + }) + .catch((error) => { + setNotif({ + message: i18n.str`Could not update category`, + type: "ERROR", + description: + error instanceof Error ? error.message : String(error), + }); + }); + }} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/list/Table.tsx @@ -155,7 +155,7 @@ function Table({ data-tooltip={i18n.str`Delete selected devices from the database`} onClick={() => onDelete(i)} > - Delete + <i18n.Translate>Delete</i18n.Translate> </button> </div> </td>