diff options
author | Florian Dold <florian@dold.me> | 2022-10-24 10:46:14 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2022-10-24 10:46:14 +0200 |
commit | 3e060b80428943c6562250a6ff77eff10a0259b7 (patch) | |
tree | d08472bc5ca28621c62ac45b229207d8215a9ea7 /packages/merchant-backoffice-ui/src/components/product | |
parent | fb52ced35ac872349b0e1062532313662552ff6c (diff) | |
download | wallet-core-3e060b80428943c6562250a6ff77eff10a0259b7.tar.xz |
repo: integrate packages from former merchant-backoffice.git
Diffstat (limited to 'packages/merchant-backoffice-ui/src/components/product')
5 files changed, 580 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx new file mode 100644 index 000000000..6504d85ba --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.stories.tsx @@ -0,0 +1,58 @@ +/* + This file is part of GNU Taler + (C) 2021 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'; + + +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/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx new file mode 100644 index 000000000..8f05c9736 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx @@ -0,0 +1,95 @@ +/* + This file is part of GNU Taler + (C) 2021 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 { useState } from "preact/hooks"; +import { FormProvider, FormErrors } from "../form/FormProvider"; +import { InputNumber } from "../form/InputNumber"; +import { InputSearchProduct } from "../form/InputSearchProduct"; +import { MerchantBackend, WithId } from "../../declaration"; +import { Translate, useTranslator } from "../../i18n"; +import { ProductMap } from "../../paths/instance/orders/create/CreatePage"; + +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 = useTranslator() + + const productWithInfiniteStock = state.product && state.product.total_stock === -1 + + const submit = (): void => { + if (!state.product) { + setErrors({ product: i18n`You must enter a valid product identifier.` }); + return; + } + if (productWithInfiniteStock) { + onAddProduct(state.product, 1) + } else { + if (!state.quantity || state.quantity <= 0) { + setErrors({ quantity: i18n`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`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`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}> + <InputSearchProduct selected={state.product} onChange={(p) => setState(v => ({ ...v, product: p }))} products={inventory} /> + { state.product && <div class="columns mt-5"> + <div class="column is-two-thirds"> + {!productWithInfiniteStock && + <InputNumber<Form> name="quantity" label={i18n`Quantity`} tooltip={i18n`how many products will be added`} /> + } + </div> + <div class="column"> + <div class="buttons is-right"> + <button class="button is-success" onClick={submit}><Translate>Add from inventory</Translate></button> + </div> + </div> + </div> } + + </FormProvider> +} diff --git a/packages/merchant-backoffice-ui/src/components/product/NonInventoryProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/NonInventoryProductForm.tsx new file mode 100644 index 000000000..397efe616 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/product/NonInventoryProductForm.tsx @@ -0,0 +1,146 @@ +/* + This file is part of GNU Taler + (C) 2021 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 { Fragment, h, VNode } from "preact"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import * as yup from 'yup'; +import { FormErrors, FormProvider } from "../form/FormProvider"; +import { Input } from "../form/Input"; +import { InputCurrency } from "../form/InputCurrency"; +import { InputImage } from "../form/InputImage"; +import { InputNumber } from "../form/InputNumber"; +import { InputTaxes } from "../form/InputTaxes"; +import { MerchantBackend } from "../../declaration"; +import { useListener } from "../../hooks/listener"; +import { Translate, useTranslator } from "../../i18n"; +import { + NonInventoryProductSchema as schema +} from '../../schemas'; + + +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 = useTranslator() + + return <Fragment> + <div class="buttons"> + <button class="button is-success" data-tooltip={i18n`describe and add a product that is not in the inventory list`} onClick={() => setShowCreateProduct(true)} ><Translate>Add custom product</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`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)} ><Translate>Cancel</Translate></button> + <button class="button is-info " disabled={!submitForm} onClick={submitForm} ><Translate>Confirm</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 = useTranslator() + + return <div> + <FormProvider<NonInventoryProduct> name="product" errors={errors} object={value} valueHandler={valueHandler} > + + <InputImage<NonInventoryProduct> name="image" label={i18n`Image`} tooltip={i18n`photo of the product`} /> + <Input<NonInventoryProduct> name="description" inputType="multiline" label={i18n`Description`} tooltip={i18n`full product description`} /> + <Input<NonInventoryProduct> name="unit" label={i18n`Unit`} tooltip={i18n`name of the product unit`} /> + <InputCurrency<NonInventoryProduct> name="price" label={i18n`Price`} tooltip={i18n`amount in the current currency`} /> + + <InputNumber<NonInventoryProduct> name="quantity" label={i18n`Quantity`} tooltip={i18n`how many products will be added`} /> + + <InputTaxes<NonInventoryProduct> name="taxes" label={i18n`Taxes`} /> + + </FormProvider> + </div> +} diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx new file mode 100644 index 000000000..9434d3de8 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/product/ProductForm.tsx @@ -0,0 +1,176 @@ +/* + This file is part of GNU Taler + (C) 2021 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 { useCallback, useEffect, useState } from "preact/hooks"; +import * as yup from "yup"; +import { useBackendContext } from "../../context/backend"; +import { MerchantBackend } from "../../declaration"; +import { useTranslator } from "../../i18n"; +import { + ProductCreateSchema as createSchema, + ProductUpdateSchema as updateSchema, +} from "../../schemas"; +import { FormProvider, FormErrors } from "../form/FormProvider"; +import { Input } from "../form/Input"; +import { InputCurrency } from "../form/InputCurrency"; +import { InputImage } from "../form/InputImage"; +import { InputNumber } from "../form/InputNumber"; +import { InputStock, Stock } from "../form/InputStock"; +import { InputTaxes } from "../form/InputTaxes"; +import { InputWithAddon } from "../form/InputWithAddon"; + +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 backend = useBackendContext(); + const i18n = useTranslator(); + + return ( + <div> + <FormProvider<Entity> + name="product" + errors={errors} + object={value} + valueHandler={valueHandler} + > + {alreadyExist ? undefined : ( + <InputWithAddon<Entity> + name="product_id" + addonBefore={`${backend.url}/product/`} + label={i18n`ID`} + tooltip={i18n`product identification to use in URLs (for internal use only)`} + /> + )} + <InputImage<Entity> + name="image" + label={i18n`Image`} + tooltip={i18n`illustration of the product for customers`} + /> + <Input<Entity> + name="description" + inputType="multiline" + label={i18n`Description`} + tooltip={i18n`product description for customers`} + /> + <InputNumber<Entity> + name="minimum_age" + label={i18n`Age restricted`} + tooltip={i18n`is this product restricted for customer below certain age?`} + /> + <Input<Entity> + name="unit" + label={i18n`Unit`} + tooltip={i18n`unit describing quantity of product sold (e.g. 2 kilograms, 5 liters, 3 items, 5 meters) for customers`} + /> + <InputCurrency<Entity> + name="price" + label={i18n`Price`} + tooltip={i18n`sale price for customers, including taxes, for above units of the product`} + /> + <InputStock + name="stock" + label={i18n`Stock`} + alreadyExist={alreadyExist} + tooltip={i18n`product inventory for products with finite supply (for internal use only)`} + /> + <InputTaxes<Entity> + name="taxes" + label={i18n`Taxes`} + tooltip={i18n`taxes included in the product price, exposed to customers`} + /> + </FormProvider> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx new file mode 100644 index 000000000..ff141bb39 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx @@ -0,0 +1,105 @@ +/* + This file is part of GNU Taler + (C) 2021 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 { MerchantBackend } from "../../declaration"; +import { Translate } from "../../i18n"; + +interface Props { + list: MerchantBackend.Product[]; + actions?: { + name: string; + tooltip: string; + handler: (d: MerchantBackend.Product, index: number) => void; + }[]; +} +export function ProductList({ list, actions = [] }: Props): VNode { + return ( + <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <Translate>image</Translate> + </th> + <th> + <Translate>description</Translate> + </th> + <th> + <Translate>quantity</Translate> + </th> + <th> + <Translate>unit price</Translate> + </th> + <th> + <Translate>total price</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> + ); +} |