merchant-backoffice

ZZZ: Inactive/Deprecated
Log | Files | Refs | Submodules | README

commit f2dfda17f27d5a842eaa77711e411313ddfc6039
parent 234a34b4235d9736cab2bb8723c29008e837fd60
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu,  8 Apr 2021 09:34:25 -0300

new order (WIP)

Diffstat:
ADESIGN.md | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/frontend/src/components/form/Input.tsx | 5+++--
Mpackages/frontend/src/components/form/InputGroup.tsx | 17++++++++++++-----
Apackages/frontend/src/components/form/InputSearchProduct.tsx | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/frontend/src/components/form/InputSelector.tsx | 1-
Mpackages/frontend/src/components/form/InputWithAddon.tsx | 10++++++----
Mpackages/frontend/src/components/menu/index.tsx | 3++-
Mpackages/frontend/src/components/modal/index.tsx | 4++--
Mpackages/frontend/src/declaration.d.ts | 30+++++++++++++++++-------------
Mpackages/frontend/src/hooks/index.ts | 14++++++++++++++
Mpackages/frontend/src/hooks/product.ts | 4++--
Mpackages/frontend/src/messages/en.po | 28++++++++++++++++++++++++++--
Mpackages/frontend/src/paths/instance/orders/create/CreatePage.tsx | 264++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Apackages/frontend/src/paths/instance/orders/create/InventoryProductForm.tsx | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/paths/instance/orders/create/NonInventoryProductForm.tsx | 39+++++++++++++++++++++++++++++++++++++++
Mpackages/frontend/src/paths/instance/orders/list/Table.tsx | 4++--
Apackages/frontend/src/paths/instance/products/create/CreatePage.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/paths/instance/products/create/CreatedSuccessfully.tsx | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/frontend/src/paths/instance/products/create/ProductForm.tsx | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/frontend/src/paths/instance/products/create/index.tsx | 41++++++++++++++++++++++++++++++++++++++---
Mpackages/frontend/src/paths/instance/products/list/Table.tsx | 4++--
Mpackages/frontend/src/paths/instance/products/list/index.tsx | 4++--
Mpackages/frontend/src/paths/instance/tips/list/Table.tsx | 4++--
Mpackages/frontend/src/paths/instance/transfers/list/Table.tsx | 4++--
Mpackages/frontend/src/schemas/index.ts | 8++++++++
Mpackages/frontend/src/scss/main.scss | 4++++
Mpackages/frontend/src/utils/table.ts | 6++----
27 files changed, 855 insertions(+), 79 deletions(-)

diff --git a/DESIGN.md b/DESIGN.md @@ -0,0 +1,58 @@ +# Page internal routing + +* The SPA is loaded from the BACKOFFICE_URL + +* The view to be rendered is decided by the URL fragment + +* Query parameters that may affect routing + + - instance: use from the default instance to mimic another instance management + +* The user must provide BACKEND_URL or BACKOFFICE_URL will use as default + +* Token for querying the backend will be saved in localStorage under backend-token-${name} + +# HTTP queries to the backend + +HTTP queries will have 4 states: + +* loading: request did not end yet. data and error are undefined + +* ok: data has information, http response status == 200 + +* clientError: http response status is between 400 and 499 + + - notfound: http status 404 + + - unauthorized: http status 401 + +* serverError: http response status is grater than 500 + +There are categories of queries: + + * sync: getting information for the page rendering + + * async: performing an CRUD operation + +## Loading the page information (sync) + +In this scenario, a failed request will make the app flow to break. + +When receiving an notfound error a generic not found page will be shown. If the BACKEND_URL points to a default instance it should send the user to create the instance. + +When receiving an unauthorized error, the user should be prompted with a login form. + +When receiving an another error (400 < http status < 600), the login form should be shown with an error message using the hint from the backend. + +On other unexpected error (like network error), the login form should be shown with and error message. + +## CRUD operation (async) + +In this scenario, a failed request does not break the flow but a message will be prompted. + +# Forms + +# Custom Hooks + +# Contexts + diff --git a/packages/frontend/src/components/form/Input.tsx b/packages/frontend/src/components/form/Input.tsx @@ -29,6 +29,7 @@ interface Props<T> { expand?: boolean; toStr?: (v?: any) => string; fromStr?: (s: string) => any; + inputExtra?: any, } const defaultToString = (f?: any): string => f || '' @@ -38,7 +39,7 @@ const TextInput = ({inputType, error, ...rest}:any) => inputType === 'multiline' <textarea {...rest} class={error ? "textarea is-danger" : "textarea"} rows="3" /> : <input {...rest} class={error ? "input is-danger" : "input"} type={inputType} />; -export function Input<T>({ name, readonly, expand, inputType, fromStr = defaultFromString, toStr = defaultToString }: Props<keyof T>): VNode { +export function Input<T>({ name, readonly, expand, inputType, inputExtra, fromStr = defaultFromString, toStr = defaultToString }: Props<keyof T>): VNode { const { error, value, onChange } = useField<T>(name); const placeholder = useMessage(`fields.instance.${name}.placeholder`); @@ -56,7 +57,7 @@ export function Input<T>({ name, readonly, expand, inputType, fromStr = defaultF <div class="field-body is-flex-grow-3"> <div class="field"> <p class={ expand ? "control is-expanded" : "control" }> - <TextInput error={error} + <TextInput error={error} {...inputExtra} inputType={inputType} placeholder={placeholder} readonly={readonly} name={String(name)} value={toStr(value)} diff --git a/packages/frontend/src/components/form/InputGroup.tsx b/packages/frontend/src/components/form/InputGroup.tsx @@ -18,16 +18,17 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { h, VNode } from "preact"; +import { ComponentChildren, h, VNode } from "preact"; import { Message } from "preact-messages"; import { useState } from "preact/hooks"; export interface Props<T> { name: keyof T; - children: VNode[] | VNode; + children: ComponentChildren; + alternative?: ComponentChildren; } -export function InputGroup<T>({ name, children }: Props<T>): VNode { +export function InputGroup<T>({ name, children, alternative}: Props<T>): VNode { const [active, setActive] = useState(false); return <div class="card"> <header class="card-header"> @@ -42,10 +43,16 @@ export function InputGroup<T>({ name, children }: Props<T>): VNode { </span> </button> </header> - <div class={active ? "card-content" : "is-hidden"}> + { active ? <div class="card-content"> <div class="content"> {children} </div> - </div> + </div> : ( + alternative ? <div class="card-content"> + <div class="content"> + {alternative} + </div> + </div> : undefined + ) } </div>; } diff --git a/packages/frontend/src/components/form/InputSearchProduct.tsx b/packages/frontend/src/components/form/InputSearchProduct.tsx @@ -0,0 +1,111 @@ +/* + 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 { MerchantBackend, WithId } from "../../declaration"; +import { InputWithAddon } from "./InputWithAddon"; +import { FormErrors, FormProvider, useField } from "./Field"; +import { useInstanceProducts } from "../../hooks/product"; +import { useState } from "preact/hooks"; + +type Entity = MerchantBackend.Products.ProductDetail & WithId + +export interface Props { + selected?: Entity; + onChange: (p?: Entity) => void; +} + +interface ProductSearch { + name: string; +} + +export function InputSearchProduct<T>({ selected, onChange }: Props) { + const [prodForm, setProdName] = useState<Partial<ProductSearch>>({ name: '' }) + + const errors: FormErrors<ProductSearch> = { + name: undefined + } + + if (selected) { + return <article class="media"> + <figure class="media-left"> + <p class="image is-128x128"> + <img src="https://avatars.dicebear.com/v2/gridy/Ms.-Lora-Kiehn.svg" /> + </p> + </figure> + <div class="media-content"> + <div class="content"> + <p class="media-meta">Product #{selected.id}</p> + <p>{selected.description}</p> + <div class="buttons is-right mt-5"> + <button class="button" onClick={() => onChange(undefined)}>clear</button> + </div> + </div> + </div> + </article> + } + + return <FormProvider<ProductSearch> errors={errors} object={prodForm} valueHandler={setProdName} > + + <InputWithAddon<ProductSearch> + name="name" + addonBefore={<span class="icon" ><i class="mdi mdi-magnify" /></span>} + > + <ProductList name={prodForm.name} onSelect={(p) => { + setProdName({ name: '' }) + onChange(p) + }} /> + </InputWithAddon> + + </FormProvider> + +} + +interface ProductListProps { + name?: string; + onSelect: (p: MerchantBackend.Products.ProductDetail & WithId) => void; +} + +function ProductList({ name, onSelect }: ProductListProps) { + const result = useInstanceProducts(); + + const re = new RegExp(`.*${name}.*`) + + let products = <div /> + + if (result.loading) { + products = <div class="dropdown-content"> + <div class="dropdown-item">loading...</div> + </div> + } else if (result.ok && !!name) { + products = <div class="dropdown-content"> + {result.data.filter(p => re.test(p.description)).map(p => ( + <div class="dropdown-item" onClick={() => onSelect(p)}> + {p.description} + </div> + ))} + </div> + } + return <div class="dropdown is-active"> + <div class="dropdown-menu" id="dropdown-menu" role="menu"> + {products} + </div> + </div> +} +\ No newline at end of file diff --git a/packages/frontend/src/components/form/InputSelector.tsx b/packages/frontend/src/components/form/InputSelector.tsx @@ -57,7 +57,6 @@ export function InputSelector<T>({ name, readonly, expand, values, fromStr = def onChange={(e) => { onChange(fromStr(e.currentTarget.value)) }}> <option>{placeholder}</option> {values - // .filter((l) => l !== value) .map(v => <option value={toStr(v)}>{toStr(v)}</option>)} </select> <Message id={`fields.instance.${name}.help`}> </Message> diff --git a/packages/frontend/src/components/form/InputWithAddon.tsx b/packages/frontend/src/components/form/InputWithAddon.tsx @@ -18,7 +18,7 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { h, VNode } from "preact"; +import { ComponentChildren, h, VNode } from "preact"; import { Message, useMessage } from "preact-messages"; import { useField } from "./Field"; @@ -27,17 +27,18 @@ export interface Props<T> { readonly?: boolean; expand?: boolean; inputType?: 'text' | 'number'; - addonBefore?: string; - addonAfter?: string; + addonBefore?: string | VNode; + addonAfter?: string | VNode; toStr?: (v?: any) => string; fromStr?: (s: string) => any; inputExtra?: any, + children?: ComponentChildren, } const defaultToString = (f?: any):string => f || '' const defaultFromString = (v: string):any => v as any -export function InputWithAddon<T>({ name, readonly, addonBefore, expand, inputType, inputExtra, addonAfter, toStr = defaultToString, fromStr = defaultFromString }: Props<T>): VNode { +export function InputWithAddon<T>({ name, readonly, addonBefore, children, expand, inputType, inputExtra, addonAfter, toStr = defaultToString, fromStr = defaultFromString }: Props<T>): VNode { const { error, value, onChange } = useField<T>(name); const placeholder = useMessage(`fields.instance.${name}.placeholder`); @@ -64,6 +65,7 @@ export function InputWithAddon<T>({ name, readonly, addonBefore, expand, inputTy name={String(name)} value={toStr(value)} onChange={(e): void => onChange(fromStr(e.currentTarget.value))} /> <Message id={`fields.instance.${name}.help`}> </Message> + {children} </p> {addonAfter && <div class="control"> <a class="button is-static">{addonAfter}</a> diff --git a/packages/frontend/src/components/menu/index.tsx b/packages/frontend/src/components/menu/index.tsx @@ -107,9 +107,10 @@ export function NotificationCard({ notification: n }: NotifProps) { <div class="message-header"> <p>{n.message}</p> </div> + { n.description && <div class="message-body"> {n.description} - </div> + </div> } </article> </div> </div> diff --git a/packages/frontend/src/components/modal/index.tsx b/packages/frontend/src/components/modal/index.tsx @@ -20,7 +20,7 @@ */ -import { h, VNode } from "preact"; +import { ComponentChildren, h, VNode } from "preact"; import { Message } from "preact-messages"; import { useState } from "preact/hooks"; import { MerchantBackend } from "../../declaration"; @@ -32,7 +32,7 @@ interface Props { description?: string; onCancel?: () => void; onConfirm?: () => void; - children?: VNode[]; + children?: ComponentChildren; danger?: boolean; disabled?: boolean; } diff --git a/packages/frontend/src/declaration.d.ts b/packages/frontend/src/declaration.d.ts @@ -27,6 +27,10 @@ type WireTransferIdentifierRawP = string; type RelativeTime = Duration; type ImageDataUrl = string; +export interface WithId { + id: string +} + interface Timestamp { // Milliseconds since epoch, or the special // value "forever" to represent an event that will @@ -39,7 +43,7 @@ interface Duration { d_ms: number | "forever"; } -interface WidthId { +interface WithId { id: string; } @@ -53,39 +57,39 @@ export namespace MerchantBackend { // Numeric error code unique to the condition. // The other arguments are specific to the error value reported here. code: number; - + // Human-readable description of the error, i.e. "missing parameter", "commitment violation", ... // Should give a human-readable hint about the error's nature. Optional, may change without notice! hint?: string; - + // Optional detail about the specific input value that failed. May change without notice! detail?: string; - + // Name of the parameter that was bogus (if applicable). parameter?: string; - + // Path to the argument that was bogus (if applicable). path?: string; - + // Offset of the argument that was bogus (if applicable). offset?: string; - + // Index of the argument that was bogus (if applicable). index?: string; - + // Name of the object that was bogus (if applicable). object?: string; - + // Name of the currency than was problematic (if applicable). currency?: string; - + // Expected type (if applicable). type_expected?: string; - + // Type that was provided instead (if applicable). type_actual?: string; - } - + } + // Delivery location, loosely modeled as a subset of // ISO20022's PostalAddress25. diff --git a/packages/frontend/src/hooks/index.ts b/packages/frontend/src/hooks/index.ts @@ -129,3 +129,17 @@ export function useNotNullLocalStorage(key: string, initialValue: string): [stri return [storedValue, setValue]; } +export function useListener<T>(onCall: (r: T) => void): [() => void, (listener: () => T) => void] { + const [state, setState] = useState({ run: () => { } }) + + const subscriber = (listener: () => T) => { + setState({ + run: () => onCall(listener()) + }) + } + + const activator = () => state.run() + + return [activator, subscriber] +} + diff --git a/packages/frontend/src/hooks/product.ts b/packages/frontend/src/hooks/product.ts @@ -1,7 +1,7 @@ import { useEffect } from 'preact/hooks'; import useSWR, { useSWRInfinite } from 'swr'; import { useBackendContext, useInstanceContext } from '../context/backend'; -import { MerchantBackend } from '../declaration'; +import { MerchantBackend, WithId } from '../declaration'; import { fetcher, HttpError, HttpResponse, HttpResponseOk, mutateAll, request, SwrError } from './backend'; @@ -67,7 +67,7 @@ export function useProductAPI(): ProductAPI { } -export function useInstanceProducts(): HttpResponse<(MerchantBackend.Products.ProductDetail & { id: string })[]> { +export function useInstanceProducts(): HttpResponse<(MerchantBackend.Products.ProductDetail & WithId)[]> { const { url: baseUrl, token: baseToken } = useBackendContext(); const { token: instanceToken, id, admin } = useInstanceContext(); diff --git a/packages/frontend/src/messages/en.po b/packages/frontend/src/messages/en.po @@ -384,4 +384,29 @@ msgid "fields.instance.lost.label" msgstr "add stock lost" msgid "fields.instance.price.label" -msgstr "new price" -\ No newline at end of file +msgstr "new price" + +msgid "fields.instance.inventory_products.label" +msgstr "Products from inventory" + +msgid "fields.instance.products.label" +msgstr "Products outside inventory" + +msgid "fields.instance.quantity.label" +msgstr "Quantity" + + +msgid "fields.instance.pricing.order_price.label" +msgstr "Order Price" + +msgid "fields.instance.pricing.summary.label" +msgstr "Summary" + +msgid "fields.instance.pricing.products_price.label" +msgstr "Products Price" + +msgid "fields.instance.pricing.products_taxes.label" +msgstr "Products Taxes" + +msgid "fields.instance.pricing.net.label" +msgstr "Net" diff --git a/packages/frontend/src/paths/instance/orders/create/CreatePage.tsx b/packages/frontend/src/paths/instance/orders/create/CreatePage.tsx @@ -19,66 +19,252 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { MerchantBackend } from "../../../../declaration"; -import * as yup from 'yup'; +import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { MerchantBackend, WithId } from "../../../../declaration"; import { FormErrors, FormProvider } from "../../../../components/form/Field" -import { OrderCreateSchema as schema } from "../../../../schemas" import { Message } from "preact-messages"; -import { Input } from "../../../../components/form/Input"; import { useConfigContext } from "../../../../context/backend"; +import { InputGroup } from "../../../../components/form/InputGroup"; +import { InventoryProductForm } from "./InventoryProductForm"; +import { NonInventoryProductFrom } from "./NonInventoryProductForm"; import { InputCurrency } from "../../../../components/form/InputCurrency"; - -type Entity = MerchantBackend.Orders.MinimalOrderDetail +import { Input } from "../../../../components/form/Input"; interface Props { onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; onBack?: () => void; } -interface KeyValue { - [key: string]: string; +function with_defaults(): Entity { + return { + inventoryProducts: {}, + products: [], + pricing: {} as any + }; } -function with_defaults(id?: string): Partial<Entity> { - return {}; +export interface ProductMap { + [id: string]: { + product: MerchantBackend.Products.ProductDetail & WithId, + quantity: number; + } +} + +interface Pricing { + products_price: string; + products_taxes: string; + net: string; + order_price: string; + summary: string; +} +interface Entity { + inventoryProducts: ProductMap, + products: MerchantBackend.Products.ProductAddDetail[], + pricing: Pricing; } export function CreatePage({ onCreate, onBack }: Props): VNode { const [value, valueHandler] = useState(with_defaults()) const [errors, setErrors] = useState<FormErrors<Entity>>({}) - const submit = (): void => { - try { - schema.validateSync(value, { abortEarly: false }) - const order = schema.cast(value) as Entity - onCreate({ order }); - } catch (err) { - const errors = err.inner as yup.ValidationError[] - const pathMessages = errors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: { type: cur.type, params: cur.params, message: cur.message } }), {}) - setErrors(pathMessages) - } - } + // const submit = (): void => { + // try { + // // schema.validateSync(value, { abortEarly: false }) + // // const order = schema.cast(value) as Entity + // // onCreate({ order }); + // } catch (err) { + // const errors = err.inner as yup.ValidationError[] + // const pathMessages = errors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: { type: cur.type, params: cur.params, message: cur.message } }), {}) + // setErrors(pathMessages) + // } + // } const config = useConfigContext() + const addProductToTheInventoryList = (product: MerchantBackend.Products.ProductDetail & WithId, quantity: number) => { + valueHandler(v => { + const inventoryProducts = { ...v.inventoryProducts } + inventoryProducts[product.id] = { product, quantity } + return ({ ...v, inventoryProducts }) + }) + } + + const removeProductFromTheInventoryList = (id: string) => { + valueHandler(v => { + const inventoryProducts = { ...v.inventoryProducts } + delete inventoryProducts[id] + return ({ ...v, inventoryProducts }) + }) + } + + const addNewProduct = (product: MerchantBackend.Products.ProductAddDetail) => { + valueHandler(v => { + const products = [...v.products, product] + return ({ ...v, products }) + }) + } + + const removeFromNewProduct = (index: number) => { + valueHandler(v => { + const products = [...v.products] + products.splice(index, 1) + return ({ ...v, products }) + }) + } + + const [editingProduct, setEditingProduct] = useState<MerchantBackend.Products.ProductAddDetail | undefined>(undefined) + + const inventoryList = Object.values(value.inventoryProducts) + const productList = Object.values(value.products) + + const totalPriceInventory = inventoryList.reduce((prev, cur) => sumPrices(multiplyPrice(cur.product.price, cur.quantity), prev), ':0') + const totalPriceProducts = productList.reduce((prev, cur) => sumPrices(multiplyPrice(cur.price, cur.total_stock), prev), ':0') + + const totalTaxInventory = inventoryList.reduce((prev, cur) => sumPrices(multiplyPrice(cur.product.taxes.reduce((prev, cur) => sumPrices(cur.tax, prev), ':0'), cur.quantity), prev), ':0') + const totalTaxProducts = productList.reduce((prev, cur) => sumPrices(multiplyPrice(cur.taxes.reduce((prev, cur) => sumPrices(cur.tax, prev), ':0'), cur.total_stock), prev), ':0') + + const hasProducts = inventoryList.length > 0 || productList.length > 0 + const totalPrice = sumPrices(totalPriceInventory, totalPriceProducts) + const totalTax = sumPrices(totalTaxInventory, totalTaxProducts) + + useEffect(() => { + valueHandler(v => { + return ({...v, pricing: { + ...v.pricing, + products_price: totalPrice, + products_taxes: totalTax, + order_price: totalPrice, + net: subtractPrices(totalPrice, totalTax), + }}) + }) + }, [hasProducts, totalPrice, totalTax, value.pricing]) + return <div> <section class="section is-main-section"> <div class="columns"> <div class="column" /> - <div class="column is-two-thirds"> - <FormProvider<Entity> errors={errors} object={value} valueHandler={valueHandler} > + <div class="column is-four-fifths"> + + <InputGroup name="inventory_products" alternative={ + inventoryList.length > 0 && <p> + {inventoryList.length} products, + in {inventoryList.reduce((prev, cur) => cur.quantity + prev, 0)} units, + with a total price of {totalPriceInventory} + </p> + }> + <InventoryProductForm + currentProducts={value.inventoryProducts} + onAddProduct={addProductToTheInventoryList} + /> + + {inventoryList.length > 0 && <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th>image</th> + <th>description</th> + <th>quantity</th> + <th>unit price</th> + <th>total price</th> + <th /> + </tr> + </thead> + <tbody> + {inventoryList.map((entry, index) => { + return <tr> + <td>image</td> + <td >{entry.product.description}</td> + <td > + {entry.quantity} {entry.product.unit} + </td> + <td >{entry.product.price}</td> + <td >{multiplyPrice(entry.product.price, entry.quantity)}</td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button class="button is-small is-danger jb-modal" type="button" onClick={(): void => removeProductFromTheInventoryList(entry.product.id)}> + Remove + </button> + </div> + </td> + </tr> + })} + + </tbody> + </table> + </div>} + </InputGroup> + + <InputGroup name="products" alternative={ + productList.length > 0 && <p> + {productList.length} products, + in {productList.reduce((prev, cur) => cur.total_stock + prev, 0)} units, + with a total price of {totalPriceProducts} + </p> + }> + <NonInventoryProductFrom value={editingProduct} onAddProduct={(p) => { + setEditingProduct(undefined) + addNewProduct(p) + }} /> + {productList.length > 0 && <div class="table-container"> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th>image</th> + <th>description</th> + <th>quantity</th> + <th>unit price</th> + <th>total price</th> + <th /> + </tr> + </thead> + <tbody> + {productList.map((entry, index) => { + return <tr> + <td>image</td> + <td >{entry.description}</td> + <td > + {entry.total_stock} {entry.unit} + </td> + <td >{entry.price}</td> + <td >{multiplyPrice(entry.price, entry.total_stock)}</td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button class="button is-small is-success jb-modal" type="button" onClick={(): void => { + removeFromNewProduct(index) + setEditingProduct(entry) + }}> + Edit + </button> + <button class="button is-small is-danger jb-modal" type="button" onClick={(): void => removeFromNewProduct(index)}> + Remove + </button> + </div> + </td> + </tr> + })} + </tbody> + </table> + </div>} + </InputGroup> - <InputCurrency<Entity> name="amount" currency={config.currency} /> + <FormProvider<Entity> errors={errors} object={value} valueHandler={valueHandler as any}> + {hasProducts ? <Fragment> + <InputCurrency name="pricing.products_price" readonly currency={config.currency}/> + <InputCurrency name="pricing.products_taxes" readonly currency={config.currency}/> + <InputCurrency name="pricing.order_price" currency={config.currency} /> + <InputCurrency name="pricing.net" readonly currency={config.currency} /> + </Fragment> : <Fragment> + <InputCurrency name="pricing.order_price" currency={config.currency} /> + </Fragment>} - <Input<Entity> name="summary" /> + <Input name="pricing.summary" /> </FormProvider> <div class="buttons is-right mt-5"> {onBack && <button class="button" onClick={onBack} ><Message id="Cancel" /></button>} - <button class="button is-success" onClick={submit} ><Message id="Confirm" /></button> + {/* <button class="button is-success" onClick={submit} ><Message id="Confirm" /></button> */} </div> </div> @@ -87,4 +273,23 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { </section> </div> -} -\ No newline at end of file +} + + +const multiplyPrice = (price: string, q: number) => { + const [currency, value] = price.split(':') + const total = parseInt(value, 10) * q + return `${currency}:${total}` +} + +const sumPrices = (one: string, two: string) => { + const [currency, valueOne] = one.split(':') + const [, valueTwo] = two.split(':') + return `${currency}:${parseInt(valueOne, 10) + parseInt(valueTwo, 10)}` +} + +const subtractPrices = (one: string, two: string) => { + const [currency, valueOne] = one.split(':') + const [, valueTwo] = two.split(':') + return `${currency}:${parseInt(valueOne, 10) - parseInt(valueTwo, 10)}` +} diff --git a/packages/frontend/src/paths/instance/orders/create/InventoryProductForm.tsx b/packages/frontend/src/paths/instance/orders/create/InventoryProductForm.tsx @@ -0,0 +1,60 @@ +import { h } from "preact"; +import { Message } from "preact-messages"; +import { useState } from "preact/hooks"; +import { FormErrors, FormProvider } from "../../../../components/form/Field"; +import { Input } from "../../../../components/form/Input"; +import { InputSearchProduct } from "../../../../components/form/InputSearchProduct"; +import { InputWithAddon } from "../../../../components/form/InputWithAddon"; +import { MerchantBackend, WithId } from "../../../../declaration"; +import { ProductMap } from "./CreatePage"; + +type Form = { + product: MerchantBackend.Products.ProductDetail & WithId, + quantity: number; +} + +interface Props { + currentProducts: ProductMap, + onAddProduct: (product: MerchantBackend.Products.ProductDetail & WithId, quantity: number) => void +} + +export function InventoryProductForm({ currentProducts, onAddProduct }: Props) { + const [state, setState] = useState<Partial<Form>>({}) + const [errors, setErrors] = useState<FormErrors<Form>>({}) + + const submit = (): void => { + if (!state.product) { + setErrors({ product: { message: 'select a product first' } }); + return; + } + if (!state.quantity || state.quantity <= 0) { + setErrors({ quantity: { message: 'should 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) { + setErrors({ quantity: { message: `cannot be greater than current stock and quantity previously added. max: ${currentStock - p.quantity}` } }); + return; + } + onAddProduct(state.product, state.quantity + p.quantity) + } else { + if (state.quantity > currentStock) { + setErrors({ quantity: { message: `cannot be greater than current stock ${currentStock}` } }); + return; + } + onAddProduct(state.product, state.quantity) + } + + setState({}) + } + + return <FormProvider<Form> errors={errors} object={state} valueHandler={setState}> + <InputSearchProduct selected={state.product} onChange={(p) => setState(v => ({ ...v, product: p }))} /> + <Input<Form> name="quantity" inputType="number" fromStr={(v) => parseInt(v, 10)} toStr={(v) => ""+v} inputExtra={{min:0}}/> + <div class="buttons is-right mt-5"> + <button class="button is-success" onClick={submit} >add</button> + </div> + </FormProvider> +} +\ No newline at end of file diff --git a/packages/frontend/src/paths/instance/orders/create/NonInventoryProductForm.tsx b/packages/frontend/src/paths/instance/orders/create/NonInventoryProductForm.tsx @@ -0,0 +1,38 @@ +import { Fragment, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { ConfirmModal } from "../../../../components/modal"; +import { MerchantBackend } from "../../../../declaration"; +import { useListener } from "../../../../hooks"; +import { ProductForm } from "../../products/create/ProductForm"; + +type Entity = MerchantBackend.Products.ProductAddDetail + +interface Props { + onAddProduct: (p: Entity) => void; + value?: Entity; +} +export function NonInventoryProductFrom({ value, onAddProduct }: Props) { + const [showCreateProduct, setShowCreateProduct] = useState(false) + + const editing = !!value + + useEffect(() => { + setShowCreateProduct(editing) + }, [editing]) + + const [ submitForm, addFormSubmitter ] = useListener<Entity | undefined>((result) => { + if (result) { + setShowCreateProduct(false) + onAddProduct(result) + } + }) + + return <Fragment> + <div class="buttons"> + <button class="button is-success" onClick={() => setShowCreateProduct(true)} >add new product</button> + </div> + {showCreateProduct && <ConfirmModal active onCancel={() => setShowCreateProduct(false)} onConfirm={submitForm}> + <ProductForm initial={value} onSubscribe={addFormSubmitter} /> + </ConfirmModal>} + </Fragment> +} +\ No newline at end of file diff --git a/packages/frontend/src/paths/instance/orders/list/Table.tsx b/packages/frontend/src/paths/instance/orders/list/Table.tsx @@ -29,12 +29,12 @@ import { InputCurrency } from "../../../../components/form/InputCurrency"; import { InputSelector } from "../../../../components/form/InputSelector"; import { ConfirmModal } from "../../../../components/modal"; import { useConfigContext } from "../../../../context/backend"; -import { MerchantBackend, WidthId } from "../../../../declaration" +import { MerchantBackend, WithId } from "../../../../declaration" import { RefoundSchema } from "../../../../schemas"; import { AMOUNT_REGEX } from "../../../../utils/constants"; import { Actions, buildActions } from "../../../../utils/table"; -type Entity = MerchantBackend.Orders.OrderHistoryEntry & { id: string } +type Entity = MerchantBackend.Orders.OrderHistoryEntry & WithId interface Props { instances: Entity[]; onRefund: (id: string, value: MerchantBackend.Orders.RefundRequest) => void; diff --git a/packages/frontend/src/paths/instance/products/create/CreatePage.tsx b/packages/frontend/src/paths/instance/products/create/CreatePage.tsx @@ -0,0 +1,84 @@ +/* + 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 } from "preact"; +import { useState } from "preact/hooks"; +import { MerchantBackend } from "../../../../declaration"; +import * as yup from 'yup'; +import { FormErrors, FormProvider } from "../../../../components/form/Field" +import { ProductCreateSchema as schema } from '../../../../schemas' +import { Message } from "preact-messages"; +import { Input } from "../../../../components/form/Input"; +import { InputSecured } from "../../../../components/form/InputSecured"; +import { InputWithAddon } from "../../../../components/form/InputWithAddon"; +import { InputGroup } from "../../../../components/form/InputGroup"; +import { useConfigContext, useBackendContext } from "../../../../context/backend"; +import { InputDuration } from "../../../../components/form/InputDuration"; +import { InputCurrency } from "../../../../components/form/InputCurrency"; +import { InputPayto } from "../../../../components/form/InputPayto"; +import { ProductForm } from "./ProductForm"; +import { useListener } from "../../../../hooks"; + +type Entity = MerchantBackend.Products.ProductAddDetail + +interface Props { + onCreate: (d: Entity) => void; + onBack?: () => void; +} + + +function with_defaults(id?: string): Partial<Entity> { + return { + + }; +} + +export function CreatePage({ onCreate, onBack }: Props): VNode { + + const [submitForm, addFormSubmitter] = useListener<Entity | undefined>((result) => { + if (result) onCreate(result) + }) + + return <div> + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-two-thirds"> + {/* <FormProvider<Entity> errors={errors} object={value} valueHandler={valueHandler} > + + <Input<Entity> name="description" /> + <InputCurrency<Entity> name="price" currency={config.currency} /> + <Input<Entity> name="total_stock" inputType="number" /> + + </FormProvider> */} + <ProductForm onSubscribe={addFormSubmitter} /> + + <div class="buttons is-right mt-5"> + {onBack && <button class="button" onClick={onBack} ><Message id="Cancel" /></button>} + <button class="button is-success" onClick={submitForm} ><Message id="Confirm" /></button> + </div> + + </div> + <div class="column" /> + </div> + </section> + </div> +} +\ No newline at end of file diff --git a/packages/frontend/src/paths/instance/products/create/CreatedSuccessfully.tsx b/packages/frontend/src/paths/instance/products/create/CreatedSuccessfully.tsx @@ -0,0 +1,68 @@ +/* + 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 } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully"; +import { useOrderAPI } from "../../../../hooks/order"; +import { Entity } from "./index"; + +interface Props { + entity: Entity; + onConfirm: () => void; + onCreateAnother?: () => void; +} + +export function CreatedSuccessfully({ entity, onConfirm, onCreateAnother }: Props) { + + return <Template onConfirm={onConfirm} onCreateAnother={onCreateAnother}> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label">Amount</label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input class="input" readonly value={entity.price} /> + </p> + </div> + </div> + </div> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label">Summary</label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input class="input" readonly value={entity.description} /> + </p> + </div> + </div> + </div> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label">Order ID</label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input class="input" readonly value={entity.total_stock} /> + </p> + </div> + </div> + </div> + </Template>; +} diff --git a/packages/frontend/src/paths/instance/products/create/ProductForm.tsx b/packages/frontend/src/paths/instance/products/create/ProductForm.tsx @@ -0,0 +1,51 @@ +import { h } from "preact"; +import { useCallback, useEffect, useState } from "preact/hooks"; +import { FormErrors, FormProvider } from "../../../../components/form/Field"; +import { Input } from "../../../../components/form/Input"; +import { InputCurrency } from "../../../../components/form/InputCurrency"; +import { useConfigContext } from "../../../../context/backend"; +import { MerchantBackend } from "../../../../declaration"; +import { ProductCreateSchema as schema } from '../../../../schemas' +import * as yup from 'yup'; + +type Entity = MerchantBackend.Products.ProductAddDetail + +interface Props { + onSubscribe: (c:() => Entity|undefined) => void; + initial?: Entity; +} + +export function ProductForm({onSubscribe, initial}:Props) { + const [value, valueHandler] = useState<Partial<Entity>>(initial || { + taxes:[] + }) + const [errors, setErrors] = useState<FormErrors<Entity>>({}) + + const submit = useCallback((): Entity|undefined => { + try { + schema.validateSync(value, { abortEarly: false }) + return schema.cast(value) as any as Entity + // onCreate(schema.cast(value) as any as Entity ); + } catch (err) { + const errors = err.inner as yup.ValidationError[] + const pathMessages = errors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: { type: cur.type, params: cur.params, message: cur.message } }), {}) + setErrors(pathMessages) + } + },[value]) + + const config = useConfigContext() + + useEffect(()=> { + onSubscribe(submit) + },[submit]) + + return <div> + <FormProvider<Entity> errors={errors} object={value} valueHandler={valueHandler} > + + <Input<Entity> name="description" /> + <InputCurrency<Entity> name="price" currency={config.currency} /> + <Input<Entity> name="total_stock" inputType="number" /> + + </FormProvider> + </div> +} +\ No newline at end of file diff --git a/packages/frontend/src/paths/instance/products/create/index.tsx b/packages/frontend/src/paths/instance/products/create/index.tsx @@ -19,8 +19,43 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { h, VNode } from 'preact'; +import { Fragment, h, VNode } from 'preact'; +import { useState } from 'preact/hooks'; +import { NotificationCard } from '../../../../components/menu'; +import { MerchantBackend } from '../../../../declaration'; +import { useOrderAPI } from '../../../../hooks/order'; +import { Notification } from '../../../../utils/types'; +import { CreatePage } from './CreatePage'; +import { CreatedSuccessfully } from './CreatedSuccessfully'; +import { useProductAPI } from '../../../../hooks/product'; -export default function ():VNode { - return <div>product list page</div> +export type Entity = MerchantBackend.Products.ProductAddDetail +interface Props { + onBack?: () => void; + onConfirm: () => void; +} +export default function ({ onConfirm, onBack }: Props): VNode { + const { createProduct } = useProductAPI() + const [notif, setNotif] = useState<Notification | undefined>(undefined) + const [createdOk, setCreatedOk] = useState<Entity | undefined>(undefined); + + if (createdOk) { + return <CreatedSuccessfully entity={createdOk} onConfirm={onConfirm} onCreateAnother={() => setCreatedOk(undefined)} /> + } + + return <Fragment> + <CreatePage + onBack={onBack} + onCreate={(request: MerchantBackend.Products.ProductAddDetail) => { + createProduct(request).then(() => { + setCreatedOk(request) + }).catch((error) => { + setNotif({ + message: 'could not create product', + type: "ERROR", + description: error.message + }) + }) + }} /> + </Fragment> } \ No newline at end of file diff --git a/packages/frontend/src/paths/instance/products/list/Table.tsx b/packages/frontend/src/paths/instance/products/list/Table.tsx @@ -26,11 +26,11 @@ import { FormErrors, FormProvider } from "../../../../components/form/Field" import { Input } from "../../../../components/form/Input" import { InputCurrency } from "../../../../components/form/InputCurrency" import { useConfigContext } from "../../../../context/backend" -import { MerchantBackend } from "../../../../declaration" +import { MerchantBackend, WithId } from "../../../../declaration" import { useProductAPI } from "../../../../hooks/product" import { Actions, buildActions } from "../../../../utils/table" -type Entity = MerchantBackend.Products.ProductDetail & { id: string } +type Entity = MerchantBackend.Products.ProductDetail & WithId interface Props { instances: Entity[]; diff --git a/packages/frontend/src/paths/instance/products/list/index.tsx b/packages/frontend/src/paths/instance/products/list/index.tsx @@ -26,7 +26,7 @@ import { useProductAPI } from "../../../../hooks/product"; import { CardTable } from './Table'; import logo from '../../../../assets/logo.jpeg'; import { useConfigContext } from '../../../../context/backend'; -import { MerchantBackend } from '../../../../declaration'; +import { MerchantBackend, WithId } from '../../../../declaration'; import { Loading } from '../../../../components/exception/loading'; import { useInstanceProducts } from '../../../../hooks/product'; import { NotificationCard } from '../../../../components/menu'; @@ -92,7 +92,7 @@ export default function ({ onUnauthorized, onLoadError, onCreate, onSelect, onNo })) } onSelect={(product) => onSelect(product.id)} - onDelete={(prod: (MerchantBackend.Products.ProductDetail & { id: string })) => deleteProduct(prod.id)} + onDelete={(prod: (MerchantBackend.Products.ProductDetail & WithId)) => deleteProduct(prod.id)} /> </section> } \ No newline at end of file diff --git a/packages/frontend/src/paths/instance/tips/list/Table.tsx b/packages/frontend/src/paths/instance/tips/list/Table.tsx @@ -22,10 +22,10 @@ import { h, VNode } from "preact" import { Message } from "preact-messages" import { StateUpdater, useEffect, useState } from "preact/hooks" -import { MerchantBackend } from "../../../../declaration" +import { MerchantBackend, WithId } from "../../../../declaration" import { Actions, buildActions } from "../../../../utils/table" -type Entity = MerchantBackend.Tips.ReserveStatusEntry & { id: string } +type Entity = MerchantBackend.Tips.ReserveStatusEntry & WithId interface Props { instances: Entity[]; diff --git a/packages/frontend/src/paths/instance/transfers/list/Table.tsx b/packages/frontend/src/paths/instance/transfers/list/Table.tsx @@ -22,10 +22,10 @@ import { h, VNode } from "preact" import { Message } from "preact-messages" import { StateUpdater, useEffect, useState } from "preact/hooks" -import { MerchantBackend } from "../../../../declaration" +import { MerchantBackend, WithId } from "../../../../declaration" import { Actions, buildActions } from "../../../../utils/table" -type Entity = MerchantBackend.Transfers.TransferDetails & { id: string } +type Entity = MerchantBackend.Transfers.TransferDetails & WithId interface Props { instances: Entity[]; diff --git a/packages/frontend/src/schemas/index.ts b/packages/frontend/src/schemas/index.ts @@ -120,3 +120,11 @@ export const OrderCreateSchema = yup.object().shape({ .required() .test('amount', 'the amount is not valid', currencyWithAmountIsValid), }) + +export const ProductCreateSchema = yup.object().shape({ + description: yup.string().required(), + price:yup.string() + .required() + .test('amount', 'the amount is not valid', currencyWithAmountIsValid), + total_stock: yup.number().required(), +}) diff --git a/packages/frontend/src/scss/main.scss b/packages/frontend/src/scss/main.scss @@ -50,6 +50,10 @@ @import "../../node_modules/@creativebulma/bulma-tooltip/dist/bulma-tooltip.min.css"; @import "../../node_modules/bulma-timeline/dist/css/bulma-timeline.min.css"; +.notification { + background-color: transparent; +} + .timeline .timeline-item .timeline-content { padding-top: 0; } diff --git a/packages/frontend/src/utils/table.ts b/packages/frontend/src/utils/table.ts @@ -14,6 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { WithId } from "../declaration"; + /** * * @author Sebastian Javier Marchano (sebasjm) @@ -28,10 +30,6 @@ function notEmpty<TValue>(value: TValue | null | undefined): value is TValue { return value !== null && value !== undefined; } -interface WithId { - id: string -} - export function buildActions<T extends WithId>(intances: T[], selected: string[], action: 'DELETE'): Actions<T>[] { return selected.map(id => intances.find(i => i.id === id)) .filter(notEmpty)