commit 2763bcf5b4a1457e3f7fea80a0a09fed5895fd13
parent 621aa0cd8116328bd0e5243e25d8d3b383638a19
Author: Sebastian <sebasjm@gmail.com>
Date: Tue, 26 Aug 2025 12:44:28 -0300
fix #10274
Diffstat:
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 {