diff options
Diffstat (limited to 'packages/auditor-backoffice-ui/src/components/product')
5 files changed, 688 insertions, 0 deletions
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> + ); +} |