taler-typescript-core

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

commit 2763bcf5b4a1457e3f7fea80a0a09fed5895fd13
parent 621aa0cd8116328bd0e5243e25d8d3b383638a19
Author: Sebastian <sebasjm@gmail.com>
Date:   Tue, 26 Aug 2025 12:44:28 -0300

fix #10274

Diffstat:
Mpackages/merchant-backoffice-ui/src/hooks/product.ts | 52+++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/merchant-backoffice-ui/src/paths/instance/categories/update/UpdatePage.tsx | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx | 88+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx | 2+-
4 files changed, 259 insertions(+), 66 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/hooks/product.ts b/packages/merchant-backoffice-ui/src/hooks/product.ts @@ -89,7 +89,57 @@ export function useInstanceProducts() { offset, setOffset, (d) => d.serial, - PAGINATED_LIST_REQUEST + PAGINATED_LIST_REQUEST, + ); +} + +export function useInstanceProductsFromIds(ids: string[]) { + const { state, lib } = useSessionContext(); + + const [offset, setOffset] = useState<number | undefined>(); + + async function fetcher([token, bid]: [AccessToken, number]) { + const all: Array<ProductWithId | undefined> = await Promise.all( + ids.map(async (id, idx) => { + const r = await lib.instance.getProductDetails(token, id); + if (r.type === "fail") { + return undefined; + } + return { ...r.body, id: id, serial: idx }; + }), + ); + const products = all.filter(notUndefined); + + return opFixedSuccess({ products }); + } + + const { data, error } = useSWR< + | OperationOk<{ products: ProductWithId[] }> + | TalerMerchantManagementErrorsByMethod<"listProducts">, + TalerHttpError + >([state.token, offset], fetcher, { + revalidateOnFocus: true, + revalidateIfStale: true, + revalidateOnMount: true, + // normally, do not refresh + refreshInterval: 0, + dedupingInterval: undefined, + refreshWhenHidden: true, + refreshWhenOffline: true, + + keepPreviousData: false, + }); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return buildPaginatedResult( + data.body.products, + offset, + setOffset, + (d) => d.serial, + PAGINATED_LIST_REQUEST, ); } 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 @@ -19,19 +19,27 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerError, TalerMerchantApi } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; +import { + assertUnreachable, + HttpStatusCode, + TalerError, + TalerMerchantApi, +} from "@gnu-taler/taler-util"; +import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { FormProvider } from "../../../../components/form/FormProvider.js"; import { Input } from "../../../../components/form/Input.js"; -import { InputArray } from "../../../../components/form/InputArray.js"; import { useSessionContext } from "../../../../context/session.js"; import { WithId } from "../../../../declaration.js"; import { - useInstanceProducts + ProductWithId, + useInstanceProducts, + useInstanceProductsFromIds, } from "../../../../hooks/product.js"; +import emptyImage from "../../../../assets/empty.png"; type Entity = TalerMerchantApi.CategoryProductList & WithId; @@ -55,7 +63,7 @@ export function UpdatePage({ category, onUpdate, onBack }: Props): VNode { inventoryResult.type === "fail" ? [] : inventoryResult.body; - + const [state, setState] = useState< Partial<Entity & { product_map: { id: string; description: string }[] }> >({ @@ -72,7 +80,10 @@ export function UpdatePage({ category, onUpdate, onBack }: Props): VNode { .then((res) => { return res.type === "fail" ? undefined - : { id: String(prod.product_id), description: res.body.description }; + : { + id: String(prod.product_id), + description: res.body.description, + }; }); }); Promise.all(ps).then((all) => { @@ -119,23 +130,9 @@ export function UpdatePage({ category, onUpdate, onBack }: Props): VNode { label={i18n.str`Name`} tooltip={i18n.str`Name of the category`} /> - <InputArray - name="product_map" - label={i18n.str`Products`} - getSuggestion={async () => { - return inventory.map((prod) => { - return { - description: prod.description, - id: prod.id, - }; - }); - }} - help={i18n.str`Search by product description or id`} - tooltip={i18n.str`Products that this category will list.`} - unique - /> </FormProvider> + <div class="buttons is-right mt-5"> {onBack && ( <button class="button" onClick={onBack}> @@ -150,6 +147,8 @@ export function UpdatePage({ category, onUpdate, onBack }: Props): VNode { <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> </div> + <ProductListSmall onSelect={() => {}} list={category.products} /> + </div> </div> </section> @@ -160,3 +159,143 @@ export function UpdatePage({ category, onUpdate, onBack }: Props): VNode { function notEmpty<TValue>(value: TValue | null | undefined): value is TValue { return value !== null && value !== undefined; } + +function ProductListSmall({ + list, + onSelect, +}: { + onSelect: () => void; + list: TalerMerchantApi.ProductSummary[]; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useInstanceProductsFromIds(list.map((d) => d.product_id)); + if (!result) return <Loading />; + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: { + return <div> not found </div>; + } + case HttpStatusCode.Unauthorized: { + return <div> not authorized </div>; + } + default: { + assertUnreachable(result); + } + } + } + return ( + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-shopping" /> + </span> + <i18n.Translate>Products</i18n.Translate> + </p> + <div class="card-header-icon" aria-label="more options"></div> + </header> + + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {result.body.length > 0 ? ( + <Table instances={result.body} onSelect={onSelect} /> + ) : ( + <EmptyTable /> + )} + </div> + </div> + </div> + </div> + ); +} + +interface TableProps { + instances: ProductWithId[]; + onSelect: (id: Entity) => void; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; +} +function Table({ + instances, + onSelect, + onLoadMoreAfter, + onLoadMoreBefore, +}: TableProps): VNode { + const { i18n } = useTranslationContext(); + return ( + <Fragment> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Image</i18n.Translate> + </th> + <th> + <i18n.Translate>Name</i18n.Translate> + </th> + <th> + <i18n.Translate>Description</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + {instances.map((i) => { + return ( + <Fragment key={i.id}> + <tr key="info"> + <td> + <img + src={i.image ? i.image : emptyImage} + style={{ + border: "solid black 1px", + maxHeight: "2em", + width: "auto", + height: "auto", + }} + /> + </td> + <td class="has-tooltip-right" style={{ cursor: "pointer" }}> + {i.product_name} + </td> + <td + class="has-tooltip-right" + data-tooltip={i.description} + style={{ cursor: "pointer" }} + > + {i.description.length > 30 + ? i.description.substring(0, 30) + "..." + : i.description} + </td> + </tr> + </Fragment> + ); + })} + </tbody> + </table> + {onLoadMoreAfter && ( + <button + class="button is-fullwidth" + data-tooltip={i18n.str`Load more products after the last one`} + onClick={onLoadMoreAfter} + > + <i18n.Translate>Load next page</i18n.Translate> + </button> + )} + </Fragment> + ); +} + +function EmptyTable(): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="content has-text-grey has-text-centered"> + <p> + <i18n.Translate>There are no products in this category.</i18n.Translate> + </p> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx @@ -42,13 +42,13 @@ type Entity = TalerMerchantApi.ProductDetail & WithId; interface Props { instances: Entity[]; - onDelete: (id: Entity) => void; + onDelete?: (id: Entity) => void; onSelect: (product: Entity) => void; - onUpdate: ( + onUpdate?: ( id: string, data: TalerMerchantApi.ProductPatchDetail, ) => Promise<void>; - onCreate: () => void; + onCreate?: () => void; selected?: boolean; onLoadMoreBefore?: () => void; onLoadMoreAfter?: () => void; @@ -77,16 +77,18 @@ export function CardTable({ <i18n.Translate>Inventory</i18n.Translate> </p> <div class="card-header-icon" aria-label="more options"> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`Add product to inventory`} - > - <button class="button is-info" type="button" onClick={onCreate}> - <span class="icon is-small"> - <i class="mdi mdi-plus mdi-36px" /> - </span> - </button> - </span> + {!onCreate ? undefined : ( + <span + class="has-tooltip-left" + data-tooltip={i18n.str`Add product to inventory`} + > + <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"> @@ -116,11 +118,10 @@ interface TableProps { rowSelection: string | undefined; instances: Entity[]; onSelect: (id: Entity) => void; - onUpdate: ( - id: string, - data: TalerMerchantApi.ProductPatchDetail, - ) => Promise<void>; - onDelete: (id: Entity) => void; + onUpdate: + | ((id: string, data: TalerMerchantApi.ProductPatchDetail) => Promise<void>) + | undefined; + onDelete: ((id: Entity) => void) | undefined; rowSelectionHandler: StateUpdater<string | undefined>; onLoadMoreBefore?: () => void; onLoadMoreAfter?: () => void; @@ -210,7 +211,7 @@ function Table({ <tr key="info"> <td onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) + onUpdate && rowSelection !== i.id && rowSelectionHandler(i.id) } style={{ cursor: "pointer" }} > @@ -228,17 +229,17 @@ function Table({ class="has-tooltip-right" data-tooltip={i.product_name} onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) + onUpdate && rowSelection !== i.id && rowSelectionHandler(i.id) } style={{ cursor: "pointer" }} > - {i.product_name} + {i.product_name} </td> <td class="has-tooltip-right" data-tooltip={i.description} onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) + onUpdate && rowSelection !== i.id && rowSelectionHandler(i.id) } style={{ cursor: "pointer" }} > @@ -248,7 +249,7 @@ function Table({ </td> <td onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) + onUpdate && rowSelection !== i.id && rowSelectionHandler(i.id) } style={{ cursor: "pointer" }} > @@ -258,7 +259,7 @@ function Table({ <Fragment> <td onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) + onUpdate && rowSelection !== i.id && rowSelectionHandler(i.id) } style={{ cursor: "pointer" }} > @@ -266,7 +267,7 @@ function Table({ </td> <td onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) + onUpdate && rowSelection !== i.id && rowSelectionHandler(i.id) } style={{ cursor: "pointer" }} > @@ -278,7 +279,7 @@ function Table({ )} <td onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) + onUpdate && rowSelection !== i.id && rowSelectionHandler(i.id) } style={{ cursor: "pointer" }} > @@ -286,7 +287,7 @@ function Table({ </td> <td onClick={() => - rowSelection !== i.id && rowSelectionHandler(i.id) + onUpdate && rowSelection !== i.id && rowSelectionHandler(i.id) } style={{ cursor: "pointer" }} > @@ -308,18 +309,20 @@ function Table({ <i18n.Translate>Update</i18n.Translate> </button> </span> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`Remove this product from the database`} - > - <button - class="button is-small is-danger" - type="button" - onClick={(): void => onDelete(i)} + {!onDelete ? undefined : ( + <span + class="has-tooltip-left" + data-tooltip={i18n.str`Remove this product from the database`} > - <i18n.Translate>Delete</i18n.Translate> - </button> - </span> + <button + class="button is-small is-danger" + type="button" + onClick={(): void => onDelete(i)} + > + <i18n.Translate>Delete</i18n.Translate> + </button> + </span> + )} </div> </td> </tr> @@ -328,11 +331,12 @@ function Table({ <td colSpan={10}> <FastProductUpdateForm product={i} - onUpdate={(prod) => - onUpdate(i.id, prod).then(() => + onUpdate={async (prod) => { + if (!onUpdate) return; + return onUpdate(i.id, prod).then(() => rowSelectionHandler(undefined), - ) - } + ); + }} onCancel={() => rowSelectionHandler(undefined)} /> </td> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx @@ -42,7 +42,7 @@ import { CardTable } from "./Table.js"; import { WithId } from "../../../../declaration.js"; interface Props { - onCreate: () => void; + onCreate: undefined | (() => void); onSelect: (id: string) => void; } export default function ProductList({ onCreate, onSelect }: Props): VNode {