merchant-backoffice

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

commit 1b6140287ad5102ce66a7f3be4e9a977192530c6
parent bf8b800bf1ee2fdb206a1ecfaba59a1f0031b55e
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon,  5 Apr 2021 18:20:04 -0300

product update

Diffstat:
MCHANGELOG.md | 2+-
Mpackages/frontend/src/InstanceRoutes.tsx | 4++--
Mpackages/frontend/src/components/form/InputCurrency.tsx | 3++-
Mpackages/frontend/src/components/form/InputWithAddon.tsx | 5+++--
Mpackages/frontend/src/messages/en.po | 13+++++++++++--
Mpackages/frontend/src/paths/instance/products/list/Table.tsx | 142+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/frontend/src/paths/instance/products/list/index.tsx | 55++++++++++++++++++++++++++++++++++++++-----------------
7 files changed, 145 insertions(+), 79 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -36,7 +36,7 @@ https://git.taler.net/anastasis.git/tree/src/cli/test_anastasis_reducer_enter_se ## [Unreleased] - fixed bug when updating token and not admin - showing a yellow bar on non-default instance navigation (admin) - - + ## [0.0.6] - 2021-03-25 - complete order list information (#6793) - complete product list information (#6792) diff --git a/packages/frontend/src/InstanceRoutes.tsx b/packages/frontend/src/InstanceRoutes.tsx @@ -53,7 +53,6 @@ import InstanceListPage from './paths/admin/list'; import InstanceCreatePage from "./paths/admin/create"; import { NotificationCard } from './components/menu'; import { Loading } from './components/exception/loading'; -import { MerchantBackend } from './declaration'; export enum InstancePaths { // details = '/', @@ -66,7 +65,6 @@ export enum InstancePaths { order_list = '/orders', order_new = '/order/new', order_details = '/order/:oid/details', - // order_new = '/order/new', // tips_list = '/tips', // tips_update = '/tip/:rid/update', @@ -192,6 +190,8 @@ export function InstanceRoutes({ id, admin }: Props): VNode { <Route path={InstancePaths.product_list} component={ProductListPage} onUnauthorized={LoginPageAccessDenied} onLoadError={LoginPageServerError} + onCreate={() => { route(InstancePaths.product_new) }} + onSelect={(id: string) => { route(InstancePaths.product_update.replace(':pid', id)) }} onNotFound={IfAdminCreateDefaultOr(NotFoundPage)} /> <Route path={InstancePaths.product_update} component={ProductUpdatePage} diff --git a/packages/frontend/src/components/form/InputCurrency.tsx b/packages/frontend/src/components/form/InputCurrency.tsx @@ -33,7 +33,8 @@ export function InputCurrency<T>({ name, readonly, expand, currency }: Props<T>) return <InputWithAddon<T> name={name} readonly={readonly} addonBefore={currency} inputType='number' expand={expand} toStr={(v?: Amount) => v?.split(':')[1] || ''} - fromStr={(v: string) => `${currency}:${v}`} + fromStr={(v: string) => !v ? '' : `${currency}:${v}`} + inputExtra={{min:0}} /> } diff --git a/packages/frontend/src/components/form/InputWithAddon.tsx b/packages/frontend/src/components/form/InputWithAddon.tsx @@ -31,12 +31,13 @@ export interface Props<T> { addonAfter?: string; toStr?: (v?: any) => string; fromStr?: (s: string) => any; + inputExtra: any, } const defaultToString = (f?: any):string => f || '' const defaultFromString = (v: string):any => v as any -export function InputWithAddon<T>({ name, readonly, addonBefore, expand, inputType, addonAfter, toStr = defaultToString, fromStr = defaultFromString }: Props<T>): VNode { +export function InputWithAddon<T>({ name, readonly, addonBefore, 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`); @@ -58,7 +59,7 @@ export function InputWithAddon<T>({ name, readonly, addonBefore, expand, inputTy <a class="button is-static">{addonBefore}</a> </div>} <p class={ expand ? "control is-expanded" : "control" }> - <input class={error ? "input is-danger" : "input"} type={inputType} + <input {...(inputExtra||{})} class={error ? "input is-danger" : "input"} type={inputType} placeholder={placeholder} readonly={readonly} name={String(name)} value={toStr(value)} onChange={(e): void => onChange(fromStr(e.currentTarget.value))} /> diff --git a/packages/frontend/src/messages/en.po b/packages/frontend/src/messages/en.po @@ -375,4 +375,13 @@ msgid "fields.product.stock.label" msgstr "Stock" msgid "fields.product.sold.label" -msgstr "Sold" -\ No newline at end of file +msgstr "Sold" + +msgid "fields.instance.stock.label" +msgstr "add stock" + +msgid "fields.instance.lost.label" +msgstr "add stock lost" + +msgid "fields.instance.price.label" +msgstr "new price" +\ 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 @@ -19,9 +19,13 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { h, VNode } from "preact" +import { Fragment, h, VNode } from "preact" import { Message } from "preact-messages" import { StateUpdater, 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 { useProductAPI } from "../../../../hooks/product" import { Actions, buildActions } from "../../../../utils/table" @@ -30,30 +34,15 @@ type Entity = MerchantBackend.Products.ProductDetail & { id: string } interface Props { instances: Entity[]; - onUpdate: (id: string) => void; onDelete: (id: Entity) => void; + onSelect: (product: Entity) => void; + onUpdate: (id: string, data: MerchantBackend.Products.ProductPatchDetail) => void; onCreate: () => void; selected?: boolean; } -export function CardTable({ instances, onCreate, onUpdate, onDelete, selected }: Props): VNode { - const [actionQueue, actionQueueHandler] = useState<Actions<Entity>[]>([]); - const [rowSelection, rowSelectionHandler] = useState<string[]>([]) - - useEffect(() => { - if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'DELETE') { - onDelete(actionQueue[0].element) - actionQueueHandler(actionQueue.slice(1)) - } - }, [actionQueue, selected, onDelete]) - - useEffect(() => { - if (actionQueue.length > 0 && !selected && actionQueue[0].type == 'UPDATE') { - onUpdate(actionQueue[0].element.id) - actionQueueHandler(actionQueue.slice(1)) - } - }, [actionQueue, selected, onUpdate]) - +export function CardTable({ instances, onCreate, onSelect, onUpdate, onDelete }: Props): VNode { + const [rowSelection, rowSelectionHandler] = useState<string | undefined>(undefined) return <div class="card has-table"> <header class="card-header"> @@ -61,10 +50,6 @@ export function CardTable({ instances, onCreate, onUpdate, onDelete, selected }: <div class="card-header-icon" aria-label="more options"> - <button class={rowSelection.length > 0 ? "button is-danger" : "is-hidden"} - type="button" onClick={(): void => actionQueueHandler(buildActions(instances, rowSelection, 'DELETE'))} > - Delete - </button> </div> <div class="card-header-icon" aria-label="more options"> <button class="button is-info" type="button" onClick={onCreate}> @@ -77,7 +62,7 @@ export function CardTable({ instances, onCreate, onUpdate, onDelete, selected }: <div class="b-table has-pagination"> <div class="table-wrapper has-mobile-cards"> {instances.length > 0 ? - <Table instances={instances} onUpdate={onUpdate} onDelete={onDelete} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} /> : + <Table instances={instances} onSelect={onSelect} onDelete={onDelete} onUpdate={onUpdate} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} /> : <EmptyTable /> } </div> @@ -86,30 +71,21 @@ export function CardTable({ instances, onCreate, onUpdate, onDelete, selected }: </div> } interface TableProps { - rowSelection: string[]; + rowSelection: string | undefined; instances: Entity[]; - onUpdate: (id: string) => void; + onSelect: (id: Entity) => void; + onUpdate: (id: string, data: MerchantBackend.Products.ProductPatchDetail) => void; onDelete: (id: Entity) => void; - rowSelectionHandler: StateUpdater<string[]>; -} - -function toggleSelected<T>(id: T): (prev: T[]) => T[] { - return (prev: T[]): T[] => prev.indexOf(id) == -1 ? [...prev, id] : prev.filter(e => e != id) + rowSelectionHandler: StateUpdater<string | undefined>; } -function Table({ rowSelection, rowSelectionHandler, instances, onUpdate, onDelete }: TableProps): VNode { +function Table({ rowSelection, rowSelectionHandler, instances, onSelect, onUpdate, onDelete }: TableProps): VNode { const { } = useProductAPI() return ( <div class="table-container"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> <thead> <tr> - <th class="is-checkbox-cell"> - <label class="b-checkbox checkbox"> - <input type="checkbox" checked={rowSelection.length === instances.length} onClick={(): void => rowSelectionHandler(rowSelection.length === instances.length ? [] : instances.map(i => i.id))} /> - <span class="check" /> - </label> - </th> <th><Message id="fields.product.image.label" /></th> <th><Message id="fields.product.description.label" /></th> <th><Message id="fields.product.sell.label" /></th> @@ -122,28 +98,31 @@ function Table({ rowSelection, rowSelectionHandler, instances, onUpdate, onDelet </thead> <tbody> {instances.map(i => { - return <tr> - <td class="is-checkbox-cell"> - <label class="b-checkbox checkbox"> - <input type="checkbox" checked={rowSelection.indexOf(i.id) != -1} onClick={(): void => rowSelectionHandler(toggleSelected(i.id))} /> - <span class="check" /> - </label> - </td> - <td onClick={(): void => onUpdate(i.id)} style={{ cursor: 'pointer' }} >{JSON.stringify(i.image)}</td> - <td onClick={(): void => onUpdate(i.id)} style={{ cursor: 'pointer' }} >{i.description}</td> - <td onClick={(): void => onUpdate(i.id)} style={{ cursor: 'pointer' }} >{i.price} / {i.unit}</td> - <td onClick={(): void => onUpdate(i.id)} style={{ cursor: 'pointer' }} >{sum(i.taxes)}</td> - <td onClick={(): void => onUpdate(i.id)} style={{ cursor: 'pointer' }} >{difference(i.price, sum(i.taxes))}</td> - <td onClick={(): void => onUpdate(i.id)} style={{ cursor: 'pointer' }} >{i.total_stock} {i.unit} ({i.next_restock?.t_ms})</td> - <td onClick={(): void => onUpdate(i.id)} style={{ cursor: 'pointer' }} >{i.total_sold} {i.unit}</td> + return <Fragment><tr> + <td onClick={() => rowSelection !== i.id && rowSelectionHandler(i.id)} style={{ cursor: 'pointer' }} >{JSON.stringify(i.image)}</td> + <td onClick={() => rowSelection !== i.id && rowSelectionHandler(i.id)} style={{ cursor: 'pointer' }} >{i.description}</td> + <td onClick={() => rowSelection !== i.id && rowSelectionHandler(i.id)} style={{ cursor: 'pointer' }} >{i.price} / {i.unit}</td> + <td onClick={() => rowSelection !== i.id && rowSelectionHandler(i.id)} style={{ cursor: 'pointer' }} >{sum(i.taxes)}</td> + <td onClick={() => rowSelection !== i.id && rowSelectionHandler(i.id)} style={{ cursor: 'pointer' }} >{difference(i.price, sum(i.taxes))}</td> + <td onClick={() => rowSelection !== i.id && rowSelectionHandler(i.id)} style={{ cursor: 'pointer' }} >{i.total_stock} {i.unit} ({i.next_restock?.t_ms})</td> + <td onClick={() => rowSelection !== i.id && rowSelectionHandler(i.id)} style={{ cursor: 'pointer' }} >{i.total_sold} {i.unit}</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 => onSelect(i)}> + Update + </button> <button class="button is-small is-danger jb-modal" type="button" onClick={(): void => onDelete(i)}> Delete - </button> + </button> </div> </td> </tr> + {rowSelection === i.id && <tr> + <td colSpan={10} > + <FastProductUpdateForm product={i} onUpdate={(prod) => onUpdate(i.id, prod)} onCancel={() => rowSelectionHandler(undefined)} /> + </td> + </tr>} + </Fragment> })} </tbody> @@ -151,6 +130,61 @@ function Table({ rowSelection, rowSelectionHandler, instances, onUpdate, onDelet </div>) } +interface FastProductUpdateFormProps { + product: Entity; + onUpdate: (data: MerchantBackend.Products.ProductPatchDetail) => void; + onCancel: () => void; +} +interface FastProductUpdate { + stock?: number; + lost?: number; + price?: string; +} + +function FastProductUpdateForm({ product, onUpdate, onCancel }: FastProductUpdateFormProps) { + const [value, valueHandler] = useState<FastProductUpdate>({}) + const config = useConfigContext() + + const errors:FormErrors<FastProductUpdate> = { + lost: !value.lost ? undefined : (value.lost < product.total_lost ? {message: `should be greater than ${product.total_lost}`} : undefined), + stock: !value.stock ? undefined : (value.stock < product.total_stock ? {message: `should be greater than ${product.total_stock}`} : undefined), + price: undefined, + } + + const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) + const isDirty = Object.keys(value).some(k => !!(value as any)[k]) + + return <Fragment> + <FormProvider<FastProductUpdate> errors={errors} object={value} valueHandler={valueHandler} > + <div class="columns"> + <div class="column"> + <Input<FastProductUpdate> name="stock" inputType="number" fromStr={(v) => parseInt(v, 10)} toStr={(v) => ""+v}/> + </div> + <div class="column"> + <Input<FastProductUpdate> name="lost" inputType="number" fromStr={(v) => parseInt(v, 10)} toStr={(v) => ""+v}/> + </div> + <div class="column"> + <InputCurrency<FastProductUpdate> name="price" currency={config.currency} /> + </div> + </div> + </FormProvider> + + <div class="buttons is-right mt-5"> + <button class="button" onClick={onCancel} ><Message id="Cancel" /></button> + <button class="button is-info" disabled={hasErrors || !isDirty} onClick={() => { + return onUpdate({ + ...product, + total_stock: value.stock || product.total_stock, + total_lost: value.lost || product.total_lost, + price: value.price || product.price, + }) + }}><Message id="Confirm" /></button> + </div> + + </Fragment> + +} + function EmptyTable(): VNode { return <div class="content has-text-grey has-text-centered"> <p> diff --git a/packages/frontend/src/paths/instance/products/list/index.tsx b/packages/frontend/src/paths/instance/products/list/index.tsx @@ -30,16 +30,21 @@ import { MerchantBackend } from '../../../../declaration'; import { Loading } from '../../../../components/exception/loading'; import { useInstanceProducts } from '../../../../hooks/product'; import { NotificationCard } from '../../../../components/menu'; +import { useState } from 'preact/hooks'; +import { Notification } from '../../../../utils/types'; interface Props { onUnauthorized: () => VNode; onNotFound: () => VNode; + onCreate: () => void; + onSelect: (id: string) => void; onLoadError: (e: HttpError) => VNode; } -export default function ({ onUnauthorized, onLoadError, onNotFound }: Props): VNode { +export default function ({ onUnauthorized, onLoadError, onCreate, onSelect, onNotFound }: Props): VNode { const result = useInstanceProducts() - const { createProduct, deleteProduct } = useProductAPI() + const { createProduct, deleteProduct, updateProduct } = useProductAPI() const { currency } = useConfigContext() + const [notif, setNotif] = useState<Notification | undefined>(undefined) if (result.clientError && result.isUnauthorized) return onUnauthorized() if (result.clientError && result.isNotfound) return onNotFound() @@ -54,24 +59,40 @@ export default function ({ onUnauthorized, onLoadError, onNotFound }: Props): VN <li>image return object when api says string</li> </ul> }} /> + <NotificationCard notification={notif} /> <CardTable instances={result.data} - onCreate={() => createProduct({ - product_id: `${Math.floor(Math.random() * 999999 + 1)}`, - address: {}, - description: '', - description_i18n: { - en: '', es: '' - }, - image: {} as string, //WTF? - price: `${currency}:${Math.floor(Math.random() * 20 + 1)}`, - taxes: [], - total_stock: Math.floor(Math.random() * 20 + 1), - unit: 'units', - next_restock: { t_ms: 'never' }, //WTF? should not be required - })} + // onCreate={onCreate} + onCreate={() => { + const product_id = `${Math.floor(Math.random() * 999999 + 1)}` + const price = `${currency}:${Math.floor(Math.random() * 20 + 1)}` + return createProduct({ + product_id, + address: {}, + description: `product with id ${product_id} and price ${price}`, + description_i18n: { + en: '', es: '' + }, + image: {} as string, //WTF? + price, + taxes: [], + total_stock: Math.floor(Math.random() * 20 + 10), + unit: 'units', + next_restock: { t_ms: 'never' }, //WTF? should not be required + }) + }} + onUpdate={(id, prod) => updateProduct(id, prod) + .then(() => setNotif({ + message: 'product updated successfully', + type: "SUCCESS" + })).catch((error) => setNotif({ + message: 'could not update the product', + type: "ERROR", + description: error.message + })) + } + onSelect={(product) => onSelect(product.id)} onDelete={(prod: (MerchantBackend.Products.ProductDetail & { id: string })) => deleteProduct(prod.id)} - onUpdate={() => null} /> </section> } \ No newline at end of file