From 7a201c3b885c5d23bf0fd0f3da32379a49b30c38 Mon Sep 17 00:00:00 2001 From: Nic Eigel Date: Sun, 14 Jan 2024 15:18:12 +0100 Subject: adding auditor-backoffice-ui --- .../instance/orders/create/Create.stories.tsx | 71 +++ .../paths/instance/orders/create/CreatePage.tsx | 705 +++++++++++++++++++++ .../orders/create/OrderCreatedSuccessfully.tsx | 114 ++++ .../src/paths/instance/orders/create/index.tsx | 114 ++++ 4 files changed, 1004 insertions(+) create mode 100644 packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx create mode 100644 packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx create mode 100644 packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx create mode 100644 packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx (limited to 'packages/auditor-backoffice-ui/src/paths/instance/orders/create') diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx new file mode 100644 index 000000000..bd9f65718 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx @@ -0,0 +1,71 @@ +/* + 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 + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { h, VNode, FunctionalComponent } from "preact"; +import { CreatePage as TestedComponent } from "./CreatePage.js"; + +export default { + title: "Pages/Order/Create", + component: TestedComponent, + argTypes: { + onCreate: { action: "onCreate" }, + goBack: { action: "goBack" }, + }, +}; + +function createExample( + Component: FunctionalComponent, + props: Partial, +) { + const r = (args: any) => ; + r.args = props; + return r; +} + +export const Example = createExample(TestedComponent, { + instanceConfig: { + default_pay_delay: { + d_us: 1000 * 1000 * 60 * 60, //one hour + }, + default_wire_transfer_delay: { + d_us: 1000 * 1000 * 60 * 60, //one hour + }, + use_stefan: true, + }, + instanceInventory: [ + { + id: "t-shirt-1", + description: "a m size t-shirt", + price: "TESTKUDOS:1", + total_stock: -1, + }, + { + id: "t-shirt-2", + price: "TESTKUDOS:1", + description: "a xl size t-shirt", + } as any, + { + id: "t-shirt-3", + price: "TESTKUDOS:1", + description: "a s size t-shirt", + } as any, + ], +}); diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx new file mode 100644 index 000000000..fbfd023c1 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx @@ -0,0 +1,705 @@ +/* + 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 + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { AbsoluteTime, Amounts, Duration, TalerProtocolDuration } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { format, isFuture } from "date-fns"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { + FormErrors, + FormProvider, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { InputCurrency } from "../../../../components/form/InputCurrency.js"; +import { InputDate } from "../../../../components/form/InputDate.js"; +import { InputDuration } from "../../../../components/form/InputDuration.js"; +import { InputGroup } from "../../../../components/form/InputGroup.js"; +import { InputLocation } from "../../../../components/form/InputLocation.js"; +import { InputNumber } from "../../../../components/form/InputNumber.js"; +import { InputToggle } from "../../../../components/form/InputToggle.js"; +import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js"; +import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js"; +import { ProductList } from "../../../../components/product/ProductList.js"; +import { useConfigContext } from "../../../../context/config.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { useSettings } from "../../../../hooks/useSettings.js"; +import { OrderCreateSchema as schema } from "../../../../schemas/index.js"; +import { rate } from "../../../../utils/amount.js"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; + +interface Props { + onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; + onBack?: () => void; + instanceConfig: InstanceConfig; + instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[]; +} +interface InstanceConfig { + use_stefan: boolean; + default_pay_delay: TalerProtocolDuration; + default_wire_transfer_delay: TalerProtocolDuration; +} + +function with_defaults(config: InstanceConfig, currency: string): Partial { + const defaultPayDeadline = Duration.fromTalerProtocolDuration(config.default_pay_delay); + const defaultWireDeadline = Duration.fromTalerProtocolDuration(config.default_wire_transfer_delay); + + return { + inventoryProducts: {}, + products: [], + pricing: {}, + payments: { + max_fee: undefined, + createToken: true, + pay_deadline: (defaultPayDeadline), + refund_deadline: (defaultPayDeadline), + wire_transfer_deadline: (defaultWireDeadline), + }, + shipping: {}, + extra: {}, + }; +} + +interface ProductAndQuantity { + product: MerchantBackend.Products.ProductDetail & WithId; + quantity: number; +} +export interface ProductMap { + [id: string]: ProductAndQuantity; +} + +interface Pricing { + products_price: string; + order_price: string; + summary: string; +} +interface Shipping { + delivery_date?: Date; + delivery_location?: MerchantBackend.Location; + fullfilment_url?: string; +} +interface Payments { + refund_deadline: Duration; + pay_deadline: Duration; + wire_transfer_deadline: Duration; + auto_refund_deadline: Duration; + max_fee?: string; + createToken: boolean; + minimum_age?: number; +} +interface Entity { + inventoryProducts: ProductMap; + products: MerchantBackend.Product[]; + pricing: Partial; + payments: Partial; + shipping: Partial; + extra: Record; +} + +const stringIsValidJSON = (value: string) => { + try { + JSON.parse(value.trim()); + return true; + } catch { + return false; + } +}; + +export function CreatePage({ + onCreate, + onBack, + instanceConfig, + instanceInventory, +}: Props): VNode { + const config = useConfigContext(); + const instance_default = with_defaults(instanceConfig, config.currency) + const [value, valueHandler] = useState(instance_default); + const zero = Amounts.zeroOfCurrency(config.currency); + const [settings, updateSettings] = useSettings() + const inventoryList = Object.values(value.inventoryProducts || {}); + const productList = Object.values(value.products || {}); + + const { i18n } = useTranslationContext(); + + const parsedPrice = !value.pricing?.order_price + ? undefined + : Amounts.parse(value.pricing.order_price); + + const errors: FormErrors = { + pricing: undefinedIfEmpty({ + summary: !value.pricing?.summary ? i18n.str`required` : undefined, + order_price: !value.pricing?.order_price + ? i18n.str`required` + : !parsedPrice + ? i18n.str`not valid` + : Amounts.isZero(parsedPrice) + ? i18n.str`must be greater than 0` + : undefined, + }), + payments: undefinedIfEmpty({ + refund_deadline: !value.payments?.refund_deadline + ? undefined + : value.payments.pay_deadline && + Duration.cmp(value.payments.refund_deadline, value.payments.pay_deadline) === -1 + ? i18n.str`refund deadline cannot be before pay deadline` + : value.payments.wire_transfer_deadline && + Duration.cmp( + value.payments.wire_transfer_deadline, + value.payments.refund_deadline, + ) === -1 + ? i18n.str`wire transfer deadline cannot be before refund deadline` + : undefined, + pay_deadline: !value.payments?.pay_deadline + ? i18n.str`required` + : value.payments.wire_transfer_deadline && + Duration.cmp( + value.payments.wire_transfer_deadline, + value.payments.pay_deadline, + ) === -1 + ? i18n.str`wire transfer deadline cannot be before pay deadline` + : undefined, + wire_transfer_deadline: !value.payments?.wire_transfer_deadline + ? i18n.str`required` + : undefined, + auto_refund_deadline: !value.payments?.auto_refund_deadline + ? undefined + : !value.payments?.refund_deadline + ? i18n.str`should have a refund deadline` + : Duration.cmp( + value.payments.refund_deadline, + value.payments.auto_refund_deadline, + ) == -1 + ? i18n.str`auto refund cannot be after refund deadline` + : undefined, + + }), + shipping: undefinedIfEmpty({ + delivery_date: !value.shipping?.delivery_date + ? undefined + : !isFuture(value.shipping.delivery_date) + ? i18n.str`should be in the future` + : undefined, + }), + }; + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submit = (): void => { + const order = value as any; //schema.cast(value); + if (!value.payments) return; + if (!value.shipping) return; + + const request: MerchantBackend.Orders.PostOrderRequest = { + order: { + amount: order.pricing.order_price, + summary: order.pricing.summary, + products: productList, + extra: undefinedIfEmpty(value.extra), + pay_deadline: !value.payments.pay_deadline ? + i18n.str`required` : + AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.pay_deadline)) + ,// : undefined, + wire_transfer_deadline: value.payments.wire_transfer_deadline + ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.wire_transfer_deadline)) + : undefined, + refund_deadline: value.payments.refund_deadline + ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.refund_deadline)) + : undefined, + auto_refund: value.payments.auto_refund_deadline + ? Duration.toTalerProtocolDuration(value.payments.auto_refund_deadline) + : undefined, + max_fee: value.payments.max_fee as string, + + delivery_date: value.shipping.delivery_date + ? { t_s: value.shipping.delivery_date.getTime() / 1000 } + : undefined, + delivery_location: value.shipping.delivery_location, + fulfillment_url: value.shipping.fullfilment_url, + minimum_age: value.payments.minimum_age, + }, + inventory_products: inventoryList.map((p) => ({ + product_id: p.product.id, + quantity: p.quantity, + })), + create_token: value.payments.createToken, + }; + + onCreate(request); + }; + + 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 = async (product: MerchantBackend.Product) => { + return valueHandler((v) => { + const products = v.products ? [...v.products, product] : []; + return { ...v, products }; + }); + }; + + const removeFromNewProduct = (index: number) => { + valueHandler((v) => { + const products = v.products ? [...v.products] : []; + products.splice(index, 1); + return { ...v, products }; + }); + }; + + const [editingProduct, setEditingProduct] = useState< + MerchantBackend.Product | undefined + >(undefined); + + const totalPriceInventory = inventoryList.reduce((prev, cur) => { + const p = Amounts.parseOrThrow(cur.product.price); + return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount; + }, zero); + + const totalPriceProducts = productList.reduce((prev, cur) => { + if (!cur.price) return zero; + const p = Amounts.parseOrThrow(cur.price); + return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount; + }, zero); + + const hasProducts = inventoryList.length > 0 || productList.length > 0; + const totalPrice = Amounts.add(totalPriceInventory, totalPriceProducts); + + const totalAsString = Amounts.stringify(totalPrice.amount); + const allProducts = productList.concat(inventoryList.map(asProduct)); + + const [newField, setNewField] = useState("") + + useEffect(() => { + valueHandler((v) => { + return { + ...v, + pricing: { + ...v.pricing, + products_price: hasProducts ? totalAsString : undefined, + order_price: hasProducts ? totalAsString : undefined, + }, + }; + }); + }, [hasProducts, totalAsString]); + + const discountOrRise = rate( + parsedPrice ?? Amounts.zeroOfCurrency(config.currency), + totalPrice.amount, + ); + + const minAgeByProducts = allProducts.reduce( + (cur, prev) => + !prev.minimum_age || cur > prev.minimum_age ? cur : prev.minimum_age, + 0, + ); + + // if there is no default pay deadline + const noDefault_payDeadline = !instance_default.payments || !instance_default.payments.pay_deadline + // and there is no defailt wire deadline + const noDefault_wireDeadline = !instance_default.payments || !instance_default.payments.wire_transfer_deadline + // user required to set the taler options + const requiresSomeTalerOptions = noDefault_payDeadline || noDefault_wireDeadline + + + return ( +
+ +
+
+
    +
  • { + updateSettings({ + ...settings, + advanceOrderMode: false + }) + }}> + + Simple + +
  • +
  • { + updateSettings({ + ...settings, + advanceOrderMode: true + }) + }}> + + Advanced + +
  • +
+
+
+
+
+ {/* // FIXME: translating plural singular */} + 0 && ( +

+ {allProducts.length} products with a total price of{" "} + {totalAsString}. +

+ ) + } + tooltip={i18n.str`Manage list of products in the order.`} + > + + + {settings.advanceOrderMode && + { + setEditingProduct(undefined); + return addNewProduct(p); + }} + /> + } + + {allProducts.length > 0 && ( + { + if (e.product_id) { + removeProductFromTheInventoryList(e.product_id); + } else { + removeFromNewProduct(index); + setEditingProduct(e); + } + }, + }, + ]} + /> + )} +
+ + + errors={errors} + object={value} + valueHandler={valueHandler as any} + > + {hasProducts ? ( + + + 0 && + (discountOrRise < 1 + ? `discount of %${Math.round( + (1 - discountOrRise) * 100, + )}` + : `rise of %${Math.round((discountOrRise - 1) * 100)}`) + } + tooltip={i18n.str`Amount to be paid by the customer`} + /> + + ) : ( + + )} + + + + {settings.advanceOrderMode && + + + {value.shipping?.delivery_date && ( + + + + )} + + + } + + {(settings.advanceOrderMode || requiresSomeTalerOptions) && + + {(settings.advanceOrderMode || noDefault_payDeadline) && } + withForever + withoutClear + tooltip={i18n.str`Time for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline. Time start to run after the order is created.`} + side={ + + + + } + />} + {settings.advanceOrderMode && } + withForever + withoutClear + tooltip={i18n.str`Time while the order can be refunded by the merchant. Time starts after the order is created.`} + side={ + + + + } + />} + {(settings.advanceOrderMode || noDefault_wireDeadline) && } + withoutClear + withForever + tooltip={i18n.str`Time for the exchange to make the wire transfer. Time starts after the order is created.`} + side={ + + + + } + />} + {settings.advanceOrderMode && } + tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`} + withForever + />} + + {settings.advanceOrderMode && } + {settings.advanceOrderMode && } + {settings.advanceOrderMode && 0 + ? i18n.str`Min age defined by the producs is ${minAgeByProducts}` + : i18n.str`No product with age restriction in this order` + } + />} + + } + + {settings.advanceOrderMode && + + {Object.keys(value.extra ?? {}).map((key) => { + + return { + if (value.extra && value.extra[key] !== undefined) { + console.log(value.extra) + delete value.extra[key] + } + valueHandler({ + ...value, + }) + }}>remove + } + /> + })} +
+
+ +
+
+
+

+ setNewField(e.currentTarget.value)} /> +

+
+
+ +
+
+ } + + +
+ {onBack && ( + + )} + +
+
+
+
+
+
+ ); +} + +function asProduct(p: ProductAndQuantity): MerchantBackend.Product { + return { + product_id: p.product.id, + image: p.product.image, + price: p.product.price, + unit: p.product.unit, + quantity: p.quantity, + description: p.product.description, + taxes: p.product.taxes, + minimum_age: p.product.minimum_age, + }; +} + + +function DeadlineHelp({ duration }: { duration?: Duration }): VNode { + const { i18n } = useTranslationContext(); + const [now, setNow] = useState(AbsoluteTime.now()) + useEffect(() => { + const iid = setInterval(() => { + setNow(AbsoluteTime.now()) + }, 60 * 1000) + return () => { + clearInterval(iid) + } + }) + if (!duration) return Disabled + const when = AbsoluteTime.addDuration(now, duration) + if (when.t_ms === "never") return No deadline + return Deadline at {format(when.t_ms, "dd/MM/yy HH:mm")} +} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx new file mode 100644 index 000000000..88a984c97 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx @@ -0,0 +1,114 @@ +/* + 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 + */ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { CreatedSuccessfully } from "../../../../components/notifications/CreatedSuccessfully.js"; +import { useOrderAPI } from "../../../../hooks/order.js"; +import { Entity } from "./index.js"; + +interface Props { + entity: Entity; + onConfirm: () => void; + onCreateAnother?: () => void; +} + +export function OrderCreatedSuccessfully({ + entity, + onConfirm, + onCreateAnother, +}: Props): VNode { + const { getPaymentURL } = useOrderAPI(); + const [url, setURL] = useState(undefined); + const { i18n } = useTranslationContext(); + useEffect(() => { + getPaymentURL(entity.response.order_id).then((response) => { + setURL(response.data); + }); + }, [getPaymentURL, entity.response.order_id]); + + return ( + +
+
+ +
+
+
+

+ +

+
+
+
+
+
+ +
+
+
+

+ +

+
+
+
+
+
+ +
+
+
+

+ +

+
+
+
+
+
+ +
+
+
+

+ +

+
+
+
+
+ ); +} diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx new file mode 100644 index 000000000..2474fd042 --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/index.tsx @@ -0,0 +1,114 @@ +/* + 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 + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ErrorType, HttpError } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useInstanceDetails } from "../../../../hooks/instance.js"; +import { useOrderAPI } from "../../../../hooks/order.js"; +import { useInstanceProducts } from "../../../../hooks/product.js"; +import { Notification } from "../../../../utils/types.js"; +import { CreatePage } from "./CreatePage.js"; +import { HttpStatusCode } from "@gnu-taler/taler-util"; + +export type Entity = { + request: MerchantBackend.Orders.PostOrderRequest; + response: MerchantBackend.Orders.PostOrderResponse; +}; +interface Props { + onBack?: () => void; + onConfirm: (id: string) => void; + onUnauthorized: () => VNode; + onNotFound: () => VNode; + onLoadError: (error: HttpError) => VNode; +} +export default function OrderCreate({ + onConfirm, + onBack, + onLoadError, + onNotFound, + onUnauthorized, +}: Props): VNode { + const { createOrder } = useOrderAPI(); + const [notif, setNotif] = useState(undefined); + + const detailsResult = useInstanceDetails(); + const inventoryResult = useInstanceProducts(); + + if (detailsResult.loading) return ; + if (inventoryResult.loading) return ; + + if (!detailsResult.ok) { + if ( + detailsResult.type === ErrorType.CLIENT && + detailsResult.status === HttpStatusCode.Unauthorized + ) + return onUnauthorized(); + if ( + detailsResult.type === ErrorType.CLIENT && + detailsResult.status === HttpStatusCode.NotFound + ) + return onNotFound(); + return onLoadError(detailsResult); + } + + if (!inventoryResult.ok) { + if ( + inventoryResult.type === ErrorType.CLIENT && + inventoryResult.status === HttpStatusCode.Unauthorized + ) + return onUnauthorized(); + if ( + inventoryResult.type === ErrorType.CLIENT && + inventoryResult.status === HttpStatusCode.NotFound + ) + return onNotFound(); + return onLoadError(inventoryResult); + } + + return ( + + + + { + createOrder(request) + .then((r) => { + return onConfirm(r.data.order_id) + }) + .catch((error) => { + setNotif({ + message: "could not create order", + type: "ERROR", + description: error.message, + }); + }); + }} + instanceConfig={detailsResult.data} + instanceInventory={inventoryResult.data} + /> + + ); +} -- cgit v1.2.3