diff options
Diffstat (limited to 'packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx')
-rw-r--r-- | packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx | 776 |
1 files changed, 776 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx new file mode 100644 index 000000000..4bb7051d6 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx @@ -0,0 +1,776 @@ +/* + 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 { AmountJson, Amounts } from "@gnu-taler/taler-util"; +import { format } from "date-fns"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { FormProvider } from "../../../../components/form/FormProvider"; +import { Input } from "../../../../components/form/Input"; +import { InputCurrency } from "../../../../components/form/InputCurrency"; +import { InputDate } from "../../../../components/form/InputDate"; +import { InputDuration } from "../../../../components/form/InputDuration"; +import { InputGroup } from "../../../../components/form/InputGroup"; +import { InputLocation } from "../../../../components/form/InputLocation"; +import { TextField } from "../../../../components/form/TextField"; +import { ProductList } from "../../../../components/product/ProductList"; +import { useBackendContext } from "../../../../context/backend"; +import { MerchantBackend } from "../../../../declaration"; +import { Translate, useTranslator } from "../../../../i18n"; +import { mergeRefunds } from "../../../../utils/amount"; +import { RefundModal } from "../list/Table"; +import { Event, Timeline } from "./Timeline"; + +type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse; +type CT = MerchantBackend.ContractTerms; + +interface Props { + onBack: () => void; + selected: Entity; + id: string; + onRefund: (id: string, value: MerchantBackend.Orders.RefundRequest) => void; +} + +type Paid = MerchantBackend.Orders.CheckPaymentPaidResponse & { + refund_taken: string; +}; +type Unpaid = MerchantBackend.Orders.CheckPaymentUnpaidResponse; +type Claimed = MerchantBackend.Orders.CheckPaymentClaimedResponse; + +function ContractTerms({ value }: { value: CT }) { + const i18n = useTranslator(); + + return ( + <InputGroup name="contract_terms" label={i18n`Contract Terms`}> + <FormProvider<CT> object={value} valueHandler={null}> + <Input<CT> + readonly + name="summary" + label={i18n`Summary`} + tooltip={i18n`human-readable description of the whole purchase`} + /> + <InputCurrency<CT> + readonly + name="amount" + label={i18n`Amount`} + tooltip={i18n`total price for the transaction`} + /> + {value.fulfillment_url && ( + <Input<CT> + readonly + name="fulfillment_url" + label={i18n`Fulfillment URL`} + tooltip={i18n`URL for this purchase`} + /> + )} + <Input<CT> + readonly + name="max_fee" + label={i18n`Max fee`} + tooltip={i18n`maximum total deposit fee accepted by the merchant for this contract`} + /> + <Input<CT> + readonly + name="max_wire_fee" + label={i18n`Max wire fee`} + tooltip={i18n`maximum wire fee accepted by the merchant`} + /> + <Input<CT> + readonly + name="wire_fee_amortization" + label={i18n`Wire fee amortization`} + tooltip={i18n`over how many customer transactions does the merchant expect to amortize wire fees on average`} + /> + <InputDate<CT> + readonly + name="timestamp" + label={i18n`Created at`} + tooltip={i18n`time when this contract was generated`} + /> + <InputDate<CT> + readonly + name="refund_deadline" + label={i18n`Refund deadline`} + tooltip={i18n`after this deadline has passed no refunds will be accepted`} + /> + <InputDate<CT> + readonly + name="pay_deadline" + label={i18n`Payment deadline`} + tooltip={i18n`after this deadline, the merchant won't accept payments for the contract`} + /> + <InputDate<CT> + readonly + name="wire_transfer_deadline" + label={i18n`Wire transfer deadline`} + tooltip={i18n`transfer deadline for the exchange`} + /> + <InputDate<CT> + readonly + name="delivery_date" + label={i18n`Delivery date`} + tooltip={i18n`time indicating when the order should be delivered`} + /> + {value.delivery_date && ( + <InputGroup + name="delivery_location" + label={i18n`Location`} + tooltip={i18n`where the order will be delivered`} + > + <InputLocation name="payments.delivery_location" /> + </InputGroup> + )} + <InputDuration<CT> + readonly + name="auto_refund" + label={i18n`Auto-refund delay`} + tooltip={i18n`how long the wallet should try to get an automatic refund for the purchase`} + /> + <Input<CT> + readonly + name="extra" + label={i18n`Extra info`} + tooltip={i18n`extra data that is only interpreted by the merchant frontend`} + /> + </FormProvider> + </InputGroup> + ); +} + +function ClaimedPage({ + id, + order, +}: { + id: string; + order: MerchantBackend.Orders.CheckPaymentClaimedResponse; +}) { + const events: Event[] = []; + if (order.contract_terms.timestamp.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.timestamp.t_s * 1000), + description: "order created", + type: "start", + }); + } + if (order.contract_terms.pay_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.pay_deadline.t_s * 1000), + description: "pay deadline", + type: "deadline", + }); + } + if (order.contract_terms.refund_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.refund_deadline.t_s * 1000), + description: "refund deadline", + type: "deadline", + }); + } + if (order.contract_terms.wire_transfer_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000), + description: "wire deadline", + type: "deadline", + }); + } + if ( + order.contract_terms.delivery_date && + order.contract_terms.delivery_date.t_s !== "never" + ) { + events.push({ + when: new Date(order.contract_terms.delivery_date?.t_s * 1000), + description: "delivery", + type: "delivery", + }); + } + + const [value, valueHandler] = useState<Partial<Claimed>>(order); + const i18n = useTranslator(); + + return ( + <div> + <section class="section"> + <div class="columns"> + <div class="column" /> + <div class="column is-10"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <Translate>Order</Translate> #{id} + <div class="tag is-info ml-4"> + <Translate>claimed</Translate> + </div> + </div> + </div> + </div> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h1 class="title">{order.contract_terms.amount}</h1> + </div> + </div> + </div> + + <div class="level"> + <div class="level-left" style={{ maxWidth: "100%" }}> + <div class="level-item" style={{ maxWidth: "100%" }}> + <div + class="content" + style={{ + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }} + > + <p> + <b> + <Translate>claimed at</Translate>: + </b>{" "} + {format( + new Date(order.contract_terms.timestamp.t_s * 1000), + "yyyy-MM-dd HH:mm:ss" + )} + </p> + </div> + </div> + </div> + </div> + </div> + </section> + + <section class="section"> + <div class="columns"> + <div class="column is-4"> + <div class="title"> + <Translate>Timeline</Translate> + </div> + <Timeline events={events} /> + </div> + <div class="column is-8"> + <div class="title"> + <Translate>Payment details</Translate> + </div> + <FormProvider<Claimed> + object={value} + valueHandler={valueHandler} + > + <Input + name="contract_terms.summary" + readonly + inputType="multiline" + label={i18n`Summary`} + /> + <InputCurrency + name="contract_terms.amount" + readonly + label={i18n`Amount`} + /> + <Input<Claimed> + name="order_status" + readonly + label={i18n`Order status`} + /> + </FormProvider> + </div> + </div> + </section> + + {order.contract_terms.products.length ? ( + <Fragment> + <div class="title"> + <Translate>Product list</Translate> + </div> + <ProductList list={order.contract_terms.products} /> + </Fragment> + ) : undefined} + + {value.contract_terms && ( + <ContractTerms value={value.contract_terms} /> + )} + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} +function PaidPage({ + id, + order, + onRefund, +}: { + id: string; + order: MerchantBackend.Orders.CheckPaymentPaidResponse; + onRefund: (id: string) => void; +}) { + const events: Event[] = []; + if (order.contract_terms.timestamp.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.timestamp.t_s * 1000), + description: "order created", + type: "start", + }); + } + if (order.contract_terms.pay_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.pay_deadline.t_s * 1000), + description: "pay deadline", + type: "deadline", + }); + } + if (order.contract_terms.refund_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.refund_deadline.t_s * 1000), + description: "refund deadline", + type: "deadline", + }); + } + if (order.contract_terms.wire_transfer_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000), + description: "wire deadline", + type: "deadline", + }); + } + if ( + order.contract_terms.delivery_date && + order.contract_terms.delivery_date.t_s !== "never" + ) { + if (order.contract_terms.delivery_date) + events.push({ + when: new Date(order.contract_terms.delivery_date?.t_s * 1000), + description: "delivery", + type: "delivery", + }); + } + order.refund_details.reduce(mergeRefunds, []).forEach((e) => { + if (e.timestamp.t_s !== "never") { + events.push({ + when: new Date(e.timestamp.t_s * 1000), + description: `refund: ${e.amount}: ${e.reason}`, + type: e.pending ? "refund" : "refund-taken", + }); + } + }); + if (order.wire_details && order.wire_details.length) { + if (order.wire_details.length > 1) { + let last: MerchantBackend.Orders.TransactionWireTransfer | null = null; + let first: MerchantBackend.Orders.TransactionWireTransfer | null = null; + let total: AmountJson | null = null; + + order.wire_details.forEach((w) => { + if (last === null || last.execution_time.t_s < w.execution_time.t_s) { + last = w; + } + if (first === null || first.execution_time.t_s > w.execution_time.t_s) { + first = w; + } + total = + total === null + ? Amounts.parseOrThrow(w.amount) + : Amounts.add(total, Amounts.parseOrThrow(w.amount)).amount; + }); + const last_time = last!.execution_time.t_s; + if (last_time !== "never") { + events.push({ + when: new Date(last_time * 1000), + description: `wired ${Amounts.stringify(total!)}`, + type: "wired-range", + }); + } + const first_time = first!.execution_time.t_s; + if (first_time !== "never") { + events.push({ + when: new Date(first_time * 1000), + description: `wire transfer started...`, + type: "wired-range", + }); + } + } else { + order.wire_details.forEach((e) => { + if (e.execution_time.t_s !== "never") { + events.push({ + when: new Date(e.execution_time.t_s * 1000), + description: `wired ${e.amount}`, + type: "wired", + }); + } + }); + } + } + + const [value, valueHandler] = useState<Partial<Paid>>(order); + const { url } = useBackendContext(); + const refundHost = url.replace(/.*:\/\//, ""); // remove protocol part + const proto = url.startsWith("http://") ? "taler+http" : "taler"; + const refundurl = `${proto}://refund/${refundHost}/${order.contract_terms.order_id}/`; + const refundable = + new Date().getTime() < order.contract_terms.refund_deadline.t_s * 1000; + const i18n = useTranslator(); + + const amount = Amounts.parseOrThrow(order.contract_terms.amount); + const refund_taken = order.refund_details.reduce((prev, cur) => { + if (cur.pending) return prev; + return Amounts.add(prev, Amounts.parseOrThrow(cur.amount)).amount; + }, Amounts.getZero(amount.currency)); + value.refund_taken = Amounts.stringify(refund_taken); + + return ( + <div> + <section class="section"> + <div class="columns"> + <div class="column" /> + <div class="column is-10"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <Translate>Order</Translate> #{id} + <div class="tag is-success ml-4"> + <Translate>paid</Translate> + </div> + {order.wired ? ( + <div class="tag is-success ml-4"> + <Translate>wired</Translate> + </div> + ) : null} + {order.refunded ? ( + <div class="tag is-danger ml-4"> + <Translate>refunded</Translate> + </div> + ) : null} + </div> + </div> + </div> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h1 class="title">{order.contract_terms.amount}</h1> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <h1 class="title"> + <div class="buttons"> + <span + class="has-tooltip-left" + data-tooltip={ + refundable + ? i18n`refund order` + : i18n`not refundable` + } + > + <button + class="button is-danger" + disabled={!refundable} + onClick={() => onRefund(id)} + > + <Translate>refund</Translate> + </button> + </span> + </div> + </h1> + </div> + </div> + </div> + + <div class="level"> + <div class="level-left" style={{ maxWidth: "100%" }}> + <div class="level-item" style={{ maxWidth: "100%" }}> + <div + class="content" + style={{ + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + // maxWidth: '100%', + }} + > + <p> + <a + href={order.contract_terms.fulfillment_url} + rel="nofollow" + target="new" + > + {order.contract_terms.fulfillment_url} + </a> + </p> + <p> + {format( + new Date(order.contract_terms.timestamp.t_s * 1000), + "yyyy/MM/dd HH:mm:ss" + )} + </p> + </div> + </div> + </div> + </div> + </div> + </section> + + <section class="section"> + <div class="columns"> + <div class="column is-4"> + <div class="title"> + <Translate>Timeline</Translate> + </div> + <Timeline events={events} /> + </div> + <div class="column is-8"> + <div class="title"> + <Translate>Payment details</Translate> + </div> + <FormProvider<Paid> + object={value} + valueHandler={valueHandler} + > + {/* <InputCurrency<Paid> name="deposit_total" readonly label={i18n`Deposit total`} /> */} + {order.refunded && ( + <InputCurrency<Paid> + name="refund_amount" + readonly + label={i18n`Refunded amount`} + /> + )} + {order.refunded && ( + <InputCurrency<Paid> + name="refund_taken" + readonly + label={i18n`Refund taken`} + /> + )} + <Input<Paid> + name="order_status" + readonly + label={i18n`Order status`} + /> + <TextField<Paid> + name="order_status_url" + label={i18n`Status URL`} + > + <a + target="_blank" + rel="noreferrer" + href={order.order_status_url} + > + {order.order_status_url} + </a> + </TextField> + {order.refunded && ( + <TextField<Paid> + name="order_status_url" + label={i18n`Refund URI`} + > + <a target="_blank" rel="noreferrer" href={refundurl}> + {refundurl} + </a> + </TextField> + )} + </FormProvider> + </div> + </div> + </section> + + {order.contract_terms.products.length ? ( + <Fragment> + <div class="title"> + <Translate>Product list</Translate> + </div> + <ProductList list={order.contract_terms.products} /> + </Fragment> + ) : undefined} + + {value.contract_terms && ( + <ContractTerms value={value.contract_terms} /> + )} + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} + +function UnpaidPage({ + id, + order, +}: { + id: string; + order: MerchantBackend.Orders.CheckPaymentUnpaidResponse; +}) { + const [value, valueHandler] = useState<Partial<Unpaid>>(order); + const i18n = useTranslator(); + return ( + <div> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h1 class="title"> + <Translate>Order</Translate> #{id} + </h1> + </div> + <div class="tag is-dark"> + <Translate>unpaid</Translate> + </div> + </div> + </div> + + <div class="level"> + <div class="level-left" style={{ maxWidth: "100%" }}> + <div class="level-item" style={{ maxWidth: "100%" }}> + <div + class="content" + style={{ + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }} + > + <p> + <b> + <Translate>pay at</Translate>: + </b>{" "} + <a + href={order.order_status_url} + rel="nofollow" + target="new" + > + {order.order_status_url} + </a> + </p> + <p> + <b> + <Translate>created at</Translate>: + </b>{" "} + {order.creation_time.t_s === "never" + ? "never" + : format( + new Date(order.creation_time.t_s * 1000), + "yyyy-MM-dd HH:mm:ss" + )} + </p> + </div> + </div> + </div> + </div> + </div> + </section> + + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider<Unpaid> object={value} valueHandler={valueHandler}> + <Input<Unpaid> + readonly + name="summary" + label={i18n`Summary`} + tooltip={i18n`human-readable description of the whole purchase`} + /> + <InputCurrency<Unpaid> + readonly + name="total_amount" + label={i18n`Amount`} + tooltip={i18n`total price for the transaction`} + /> + <Input<Unpaid> + name="order_status" + readonly + label={i18n`Order status`} + /> + <Input<Unpaid> + name="order_status_url" + readonly + label={i18n`Order status URL`} + /> + <TextField<Unpaid> name="taler_pay_uri" label={i18n`Payment URI`}> + <a target="_blank" rel="noreferrer" href={value.taler_pay_uri}> + {value.taler_pay_uri} + </a> + </TextField> + </FormProvider> + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} + +export function DetailPage({ id, selected, onRefund, onBack }: Props): VNode { + const [showRefund, setShowRefund] = useState<string | undefined>(undefined); + + const DetailByStatus = function () { + switch (selected.order_status) { + case "claimed": + return <ClaimedPage id={id} order={selected} />; + case "paid": + return <PaidPage id={id} order={selected} onRefund={setShowRefund} />; + case "unpaid": + return <UnpaidPage id={id} order={selected} />; + default: + return ( + <div> + <Translate> + Unknown order status. This is an error, please contact the + administrator. + </Translate> + </div> + ); + } + }; + + return ( + <Fragment> + {DetailByStatus()} + {showRefund && ( + <RefundModal + order={selected} + onCancel={() => setShowRefund(undefined)} + onConfirm={(value) => { + onRefund(showRefund, value); + setShowRefund(undefined); + }} + /> + )} + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <div class="buttons is-right mt-5"> + <button class="button" onClick={onBack}> + <Translate>Back</Translate> + </button> + </div> + </div> + <div class="column" /> + </div> + </Fragment> + ); +} + +async function copyToClipboard(text: string) { + return navigator.clipboard.writeText(text); +} |