From e5dc918be5adbdbd4ef1c8e57330981a9cdc35ab Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 5 Mar 2024 14:06:50 +0100 Subject: [PATCH] feat(dashboard): Order details page (#6538) **What** - Adds the different display cards to the order details page. This does not include any of the different forms for editing various aspects of an order. --- packages/admin-next/dashboard/package.json | 1 + .../public/locales/en-US/translation.json | 87 +++-- .../money-amount-cell/money-amount-cell.tsx | 19 +- .../order/country-cell/country-cell.tsx | 28 ++ .../table-cells/order/country-cell/index.ts | 1 + .../order/customer-cell/customer-cell.tsx | 4 +- .../fulfillment-status-cell.tsx | 18 +- .../table-cells/order/items-cell/index.ts | 1 - .../order/items-cell/items-cell.tsx | 26 -- .../payment-status-cell.tsx | 17 +- .../table/columns/use-order-table-columns.tsx | 33 +- .../table/filters/use-order-table-filters.tsx | 36 +- .../dashboard/src/lib/money-amount-helpers.ts | 82 ++++- .../router-provider/router-provider.tsx | 9 +- .../src/routes/orders/details/details.tsx | 3 - .../src/routes/orders/details/index.ts | 1 - .../order-customer-section/index.ts | 1 + .../order-customer-section.tsx | 319 ++++++++++++++++++ .../order-fulfillment-section/index.ts | 1 + .../order-fulfillment-section.tsx | 314 +++++++++++++++++ .../components/order-general-section/index.ts | 1 + .../order-general-section.tsx | 132 ++++++++ .../components/order-payment-section/index.ts | 1 + .../order-payment-section.tsx | 280 +++++++++++++++ .../components/order-summary-section/index.ts | 1 + .../order-summary-section.tsx | 239 +++++++++++++ .../routes/orders/order-detail/constants.ts | 2 + .../src/routes/orders/order-detail/index.ts | 2 + .../src/routes/orders/order-detail/loader.ts | 25 ++ .../orders/order-detail/order-detail.tsx | 52 +++ .../order-list-table/order-list-table.tsx | 3 +- .../product-detail/product-detail.tsx | 6 +- yarn.lock | 3 +- 33 files changed, 1620 insertions(+), 128 deletions(-) create mode 100644 packages/admin-next/dashboard/src/components/table/table-cells/order/country-cell/country-cell.tsx create mode 100644 packages/admin-next/dashboard/src/components/table/table-cells/order/country-cell/index.ts delete mode 100644 packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/index.ts delete mode 100644 packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/items-cell.tsx delete mode 100644 packages/admin-next/dashboard/src/routes/orders/details/details.tsx delete mode 100644 packages/admin-next/dashboard/src/routes/orders/details/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/order-customer-section.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/order-general-section.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-payment-section/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-payment-section/order-payment-section.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/constants.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/loader.ts create mode 100644 packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 11bd6c40ec..d82741aea5 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -35,6 +35,7 @@ "match-sorter": "^6.3.4", "medusa-react": "workspace:^", "react": "18.2.0", + "react-country-flag": "^3.1.0", "react-dom": "18.2.0", "react-focus-lock": "^2.11.1", "react-hook-form": "7.49.1", diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index 63df2daa59..e50fcc654b 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -65,6 +65,11 @@ "defaultTitle": "An error occurred", "defaultMessage": "An error occurred while rendering this page." }, + "addresses": { + "shippingAddress": "Shipping Address", + "billingAddress": "Billing Address", + "sameAsShipping": "Same as shipping address" + }, "products": { "domain": "Products", "createProductTitle": "Create Product", @@ -164,27 +169,62 @@ }, "orders": { "domain": "Orders", - "paymentStatusLabel": "Payment Status", - "paymentStatus": { - "notPaid": "Not Paid", - "awaiting": "Awaiting", - "captured": "Captured", - "partiallyRefunded": "Partially Refunded", - "refunded": "Refunded", - "canceled": "Canceled", - "requresAction": "Requires Action" + "cancelWarning": "You are about to cancel the order {{id}}. This action cannot be undone.", + "onDateFromSalesChannel": "{{date}} from {{salesChannel}}", + "summary": { + "allocateItems": "Allocate items", + "editItems": "Edit items" }, - "fulfillmentStatusLabel": "Fulfillment Status", - "fulfillmentStatus": { - "notFulfilled": "Not Fulfilled", - "partiallyFulfilled": "Partially Fulfilled", - "fulfilled": "Fulfilled", - "partiallyShipped": "Partially Shipped", - "shipped": "Shipped", - "partiallyReturned": "Partially Returned", - "returned": "Returned", - "canceled": "Canceled", - "requresAction": "Requires Action" + "payment": { + "title": "Payments", + "isReadyToBeCaptured": "Payment {{id}} is ready to be captured.", + "totalPaidByCustomer": "Total paid by customer", + "capture": "Capture", + "refund": "Refund", + "statusLabel": "Payment status", + "statusTitle": "Payment Status", + "status": { + "notPaid": "Not paid", + "awaiting": "Awaiting", + "captured": "Captured", + "partiallyRefunded": "Partially refunded", + "refunded": "Refunded", + "canceled": "Canceled", + "requresAction": "Requires action" + } + }, + "reservations": { + "allocatedLabel": "Allocated", + "notAllocatedLabel": "Not allocated" + }, + "fulfillment": { + "cancelWarning": "You are about to cancel a fulfillment. This action cannot be undone.", + "unfulfilledItems": "Unfulfilled Items", + "statusLabel": "Fulfillment status", + "statusTitle": "Fulfillment Status", + "awaitingFullfillmentBadge": "Awaiting fulfillment", + "number": "Fulfillment #{{number}}", + "status": { + "notFulfilled": "Not fulfilled", + "partiallyFulfilled": "Partially fulfilled", + "fulfilled": "Fulfilled", + "partiallyShipped": "Partially shipped", + "shipped": "Shipped", + "partiallyReturned": "Partially returned", + "returned": "Returned", + "canceled": "Canceled", + "requresAction": "Requires action" + }, + "trackingLabel": "Tracking", + "shippingFromLabel": "Shipping from", + "itemsLabel": "Items" + }, + "customer": { + "contactLabel": "Contact", + "editEmail": "Edit email", + "transferOwnership": "Transfer ownership", + "editBillingAddress": "Edit billing address", + "editShippingAddress": "Edit shipping address" } }, "draftOrders": { @@ -429,6 +469,9 @@ "orders": "Orders", "account": "Account", "total": "Total", + "subtotal": "Subtotal", + "shipping": "Shipping", + "tax": "Tax", "created": "Created", "key": "Key", "customer": "Customer", @@ -466,8 +509,10 @@ "thumbnail": "Thumbnail", "sku": "SKU", "managedInventory": "Managed inventory", + "id": "ID", "minSubtotal": "Min. Subtotal", "maxSubtotal": "Max. Subtotal", - "shippingProfile": "Shipping Profile" + "shippingProfile": "Shipping Profile", + "summary": "Summary" } } diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/common/money-amount-cell/money-amount-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/common/money-amount-cell/money-amount-cell.tsx index 57a9cf0e66..56a981054d 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/common/money-amount-cell/money-amount-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/common/money-amount-cell/money-amount-cell.tsx @@ -1,5 +1,5 @@ import { clx } from "@medusajs/ui" -import { getPresentationalAmount } from "../../../../../lib/money-amount-helpers" +import { getStylizedAmount } from "../../../../../lib/money-amount-helpers" import { PlaceholderCell } from "../placeholder-cell" type MoneyAmountCellProps = { @@ -17,18 +17,7 @@ export const MoneyAmountCell = ({ return } - const formatted = new Intl.NumberFormat(undefined, { - style: "currency", - currency: currencyCode, - currencyDisplay: "narrowSymbol", - }).format(0) - - const symbol = formatted.replace(/\d/g, "").replace(/[.,]/g, "").trim() - - const presentationAmount = getPresentationalAmount(amount, currencyCode) - const formattedTotal = new Intl.NumberFormat(undefined, { - style: "decimal", - }).format(presentationAmount) + const formatted = getStylizedAmount(amount, currencyCode) return (
- - {symbol} {formattedTotal} {currencyCode.toUpperCase()} - + {formatted}
) } diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/country-cell/country-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/country-cell/country-cell.tsx new file mode 100644 index 0000000000..33114a4793 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/country-cell/country-cell.tsx @@ -0,0 +1,28 @@ +import { Country } from "@medusajs/medusa" +import { Tooltip } from "@medusajs/ui" +import ReactCountryFlag from "react-country-flag" +import { PlaceholderCell } from "../../common/placeholder-cell" + +export const CountryCell = ({ country }: { country?: Country | null }) => { + if (!country) { + return + } + + return ( +
+ +
+ +
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/country-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/order/country-cell/index.ts new file mode 100644 index 0000000000..81953fd274 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/country-cell/index.ts @@ -0,0 +1 @@ +export * from "./country-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx index af558b959c..5dd516c5c3 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/customer-cell/customer-cell.tsx @@ -11,9 +11,7 @@ export const CustomerCell = ({ customer }: { customer: Customer | null }) => { return (
-
- {name || email} -
+ {name || email}
) } diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx index d899e85468..71add7608c 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/fulfillment-status-cell/fulfillment-status-cell.tsx @@ -12,24 +12,24 @@ export const FulfillmentStatusCell = ({ const { t } = useTranslation() const [label, color] = { - not_fulfilled: [t("orders.fulfillmentStatus.notFulfilled"), "red"], + not_fulfilled: [t("orders.fulfillment.status.notFulfilled"), "red"], partially_fulfilled: [ - t("orders.fulfillmentStatus.partiallyFulfilled"), + t("orders.fulfillment.status.partiallyFulfilled"), "orange", ], - fulfilled: [t("orders.fulfillmentStatus.fulfilled"), "green"], + fulfilled: [t("orders.fulfillment.status.fulfilled"), "green"], partially_shipped: [ - t("orders.fulfillmentStatus.partiallyShipped"), + t("orders.fulfillment.status.partiallyShipped"), "orange", ], - shipped: [t("orders.fulfillmentStatus.shipped"), "green"], + shipped: [t("orders.fulfillment.status.shipped"), "green"], partially_returned: [ - t("orders.fulfillmentStatus.partiallyReturned"), + t("orders.fulfillment.status.partiallyReturned"), "orange", ], - returned: [t("orders.fulfillmentStatus.returned"), "green"], - canceled: [t("orders.fulfillmentStatus.canceled"), "red"], - requires_action: [t("orders.fulfillmentStatus.requresAction"), "orange"], + returned: [t("orders.fulfillment.status.returned"), "green"], + canceled: [t("orders.fulfillment.status.canceled"), "red"], + requires_action: [t("orders.fulfillment.status.requresAction"), "orange"], }[status] as [string, "red" | "orange" | "green"] return {label} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/index.ts deleted file mode 100644 index 8df6791eae..0000000000 --- a/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./items-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/items-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/items-cell.tsx deleted file mode 100644 index fc0c3b3403..0000000000 --- a/packages/admin-next/dashboard/src/components/table/table-cells/order/items-cell/items-cell.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { LineItem } from "@medusajs/medusa" -import { useTranslation } from "react-i18next" - -export const ItemsCell = ({ items }: { items: LineItem[] }) => { - const { t } = useTranslation() - - return ( -
- - {t("general.items", { - count: items.length, - })} - -
- ) -} - -export const ItemsHeader = () => { - const { t } = useTranslation() - - return ( -
- {t("fields.items")} -
- ) -} diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx index db8828fa26..01eef86a8d 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/order/payment-status-cell/payment-status-cell.tsx @@ -10,13 +10,16 @@ export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => { const { t } = useTranslation() const [label, color] = { - not_paid: [t("orders.paymentStatus.notPaid"), "red"], - awaiting: [t("orders.paymentStatus.awaiting"), "orange"], - captured: [t("orders.paymentStatus.captured"), "green"], - refunded: [t("orders.paymentStatus.refunded"), "green"], - partially_refunded: [t("orders.paymentStatus.partiallyRefunded"), "orange"], - canceled: [t("orders.paymentStatus.canceled"), "red"], - requires_action: [t("orders.paymentStatus.requresAction"), "orange"], + not_paid: [t("orders.payment.status.notPaid"), "red"], + awaiting: [t("orders.payment.status.awaiting"), "orange"], + captured: [t("orders.payment.status.captured"), "green"], + refunded: [t("orders.payment.status.refunded"), "green"], + partially_refunded: [ + t("orders.payment.status.partiallyRefunded"), + "orange", + ], + canceled: [t("orders.payment.status.canceled"), "red"], + requires_action: [t("orders.payment.status.requresAction"), "orange"], }[status] as [string, "red" | "orange" | "green"] return {label} diff --git a/packages/admin-next/dashboard/src/hooks/table/columns/use-order-table-columns.tsx b/packages/admin-next/dashboard/src/hooks/table/columns/use-order-table-columns.tsx index 490db287b1..50ca39028f 100644 --- a/packages/admin-next/dashboard/src/hooks/table/columns/use-order-table-columns.tsx +++ b/packages/admin-next/dashboard/src/hooks/table/columns/use-order-table-columns.tsx @@ -9,6 +9,11 @@ import { DateCell, DateHeader, } from "../../../components/table/table-cells/common/date-cell" +import { CountryCell } from "../../../components/table/table-cells/order/country-cell" +import { + CustomerCell, + CustomerHeader, +} from "../../../components/table/table-cells/order/customer-cell" import { DisplayIdCell, DisplayIdHeader, @@ -17,10 +22,6 @@ import { FulfillmentStatusCell, FulfillmentStatusHeader, } from "../../../components/table/table-cells/order/fulfillment-status-cell" -import { - ItemsCell, - ItemsHeader, -} from "../../../components/table/table-cells/order/items-cell" import { PaymentStatusCell, PaymentStatusHeader, @@ -62,6 +63,14 @@ export const useOrderTableColumns = (props: UseOrderTableColumnsProps) => { return }, }), + columnHelper.accessor("customer", { + header: () => , + cell: ({ getValue }) => { + const customer = getValue() + + return + }, + }), columnHelper.accessor("sales_channel", { header: () => , cell: ({ getValue }) => { @@ -86,14 +95,6 @@ export const useOrderTableColumns = (props: UseOrderTableColumnsProps) => { return }, }), - columnHelper.accessor("items", { - header: () => , - cell: ({ getValue }) => { - const items = getValue() - - return - }, - }), columnHelper.accessor("total", { header: () => , cell: ({ getValue, row }) => { @@ -103,6 +104,14 @@ export const useOrderTableColumns = (props: UseOrderTableColumnsProps) => { return }, }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => { + const country = row.original.shipping_address?.country + + return + }, + }), ], [] ) diff --git a/packages/admin-next/dashboard/src/hooks/table/filters/use-order-table-filters.tsx b/packages/admin-next/dashboard/src/hooks/table/filters/use-order-table-filters.tsx index aa5b309f05..0c2b6348bc 100644 --- a/packages/admin-next/dashboard/src/hooks/table/filters/use-order-table-filters.tsx +++ b/packages/admin-next/dashboard/src/hooks/table/filters/use-order-table-filters.tsx @@ -54,36 +54,36 @@ export const useOrderTableFilters = (): Filter[] => { const paymentStatusFilter: Filter = { key: "payment_status", - label: t("orders.paymentStatusLabel"), + label: t("orders.payment.statusLabel"), type: "select", multiple: true, options: [ { - label: t("orders.paymentStatus.notPaid"), + label: t("orders.payment.status.notPaid"), value: "not_paid", }, { - label: t("orders.paymentStatus.awaiting"), + label: t("orders.payment.status.awaiting"), value: "awaiting", }, { - label: t("orders.paymentStatus.captured"), + label: t("orders.payment.status.captured"), value: "captured", }, { - label: t("orders.paymentStatus.refunded"), + label: t("orders.payment.status.refunded"), value: "refunded", }, { - label: t("orders.paymentStatus.partiallyRefunded"), + label: t("orders.payment.status.partiallyRefunded"), value: "partially_refunded", }, { - label: t("orders.paymentStatus.canceled"), + label: t("orders.payment.status.canceled"), value: "canceled", }, { - label: t("orders.paymentStatus.requresAction"), + label: t("orders.payment.status.requresAction"), value: "requires_action", }, ], @@ -91,44 +91,44 @@ export const useOrderTableFilters = (): Filter[] => { const fulfillmentStatusFilter: Filter = { key: "fulfillment_status", - label: t("orders.fulfillmentStatusLabel"), + label: t("orders.fulfillment.statusLabel"), type: "select", multiple: true, options: [ { - label: t("orders.fulfillmentStatus.notFulfilled"), + label: t("orders.fulfillment.status.notFulfilled"), value: "not_fulfilled", }, { - label: t("orders.fulfillmentStatus.fulfilled"), + label: t("orders.fulfillment.status.fulfilled"), value: "fulfilled", }, { - label: t("orders.fulfillmentStatus.partiallyFulfilled"), + label: t("orders.fulfillment.status.partiallyFulfilled"), value: "partially_fulfilled", }, { - label: t("orders.fulfillmentStatus.returned"), + label: t("orders.fulfillment.status.returned"), value: "returned", }, { - label: t("orders.fulfillmentStatus.partiallyReturned"), + label: t("orders.fulfillment.status.partiallyReturned"), value: "partially_returned", }, { - label: t("orders.fulfillmentStatus.shipped"), + label: t("orders.fulfillment.status.shipped"), value: "shipped", }, { - label: t("orders.fulfillmentStatus.partiallyShipped"), + label: t("orders.fulfillment.status.partiallyShipped"), value: "partially_shipped", }, { - label: t("orders.fulfillmentStatus.canceled"), + label: t("orders.fulfillment.status.canceled"), value: "canceled", }, { - label: t("orders.fulfillmentStatus.requresAction"), + label: t("orders.fulfillment.status.requresAction"), value: "requires_action", }, ], diff --git a/packages/admin-next/dashboard/src/lib/money-amount-helpers.ts b/packages/admin-next/dashboard/src/lib/money-amount-helpers.ts index d043af4f37..a4de8bf37d 100644 --- a/packages/admin-next/dashboard/src/lib/money-amount-helpers.ts +++ b/packages/admin-next/dashboard/src/lib/money-amount-helpers.ts @@ -1,21 +1,95 @@ import { currencies } from "./currencies" -export const getPresentationalAmount = (amount: number, currency: string) => { - const decimalDigits = currencies[currency.toUpperCase()].decimal_digits +const getDecimalDigits = (currency: string) => { + return currencies[currency.toUpperCase()]?.decimal_digits +} - if (decimalDigits === 0) { +/** + * Converts an amount from the database format (cents) to the presentational format + * + * @param amount - The amount to format + * @param currency - The currency code to format the amount in + * @returns The formatted amount + * + * @example + * getPresentationalAmount(1000, "usd") // 10 + * getPresentationalAmount(1000, "jpy") // 1000 + */ +export const getPresentationalAmount = (amount: number, currency: string) => { + const decimalDigits = getDecimalDigits(currency) + + if (!decimalDigits) { throw new Error("Currency has no decimal digits") } return amount / 10 ** decimalDigits } +/** + * Converts an amount to the database format (cents) + * @param amount - The amount to convert to the database amount + * @param currency - The currency code to convert the amount to + * @returns The amount in the database format + * + * @example + * getDbAmount(10.5, "usd") // 1050 + * getDbAmount(10, "jpy") // 10 + */ export const getDbAmount = (amount: number, currency: string) => { const decimalDigits = currencies[currency.toUpperCase()].decimal_digits - if (decimalDigits === 0) { + if (!decimalDigits) { throw new Error("Currency has no decimal digits") } return amount * 10 ** decimalDigits } + +/** + * Returns a formatted amount based on the currency code using the browser's locale + * @param amount - The amount to format + * @param currencyCode - The currency code to format the amount in + * @returns - The formatted amount + * + * @example + * getFormattedAmount(1000, "usd") // '$10.00' if the browser's locale is en-US + * getFormattedAmount(1000, "usd") // '10,00 $' if the browser's locale is fr-FR + */ +export const getLocaleAmount = (amount: number, currencyCode: string) => { + const formatter = new Intl.NumberFormat(undefined, { + style: "currency", + currencyDisplay: "narrowSymbol", + currency: currencyCode, + }) + + return formatter.format(getPresentationalAmount(amount, currencyCode)) +} + +export const getNativeSymbol = (currencyCode: string) => { + const formatted = new Intl.NumberFormat(undefined, { + style: "currency", + currency: currencyCode, + currencyDisplay: "narrowSymbol", + }).format(0) + + return formatted.replace(/\d/g, "").replace(/[.,]/g, "").trim() +} + +/** + * In some cases we want to display the amount with the currency code and symbol, + * in the format of "symbol amount currencyCode". This breaks from the + * user's locale and is only used in cases where we want to display the + * currency code and symbol explicitly, e.g. for totals. + */ +export const getStylizedAmount = (amount: number, currencyCode: string) => { + const symbol = getNativeSymbol(currencyCode) + const decimalDigits = getDecimalDigits(currencyCode) + const presentationAmount = getPresentationalAmount(amount, currencyCode) + + const total = presentationAmount.toLocaleString(undefined, { + minimumFractionDigits: decimalDigits, + maximumFractionDigits: decimalDigits, + }) + + return `${symbol} ${total} ${currencyCode.toUpperCase()}` +} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx index 31e18640a8..9bfa8a61ca 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx @@ -3,6 +3,7 @@ import type { AdminCustomerGroupsRes, AdminCustomersRes, AdminGiftCardsRes, + AdminOrdersRes, AdminProductsRes, AdminPublishableApiKeysRes, AdminRegionsRes, @@ -87,12 +88,16 @@ const router = createBrowserRouter([ }, children: [ { - index: true, + path: "", lazy: () => import("../../routes/orders/order-list"), }, { path: ":id", - lazy: () => import("../../routes/orders/details"), + lazy: () => import("../../routes/orders/order-detail"), + handle: { + crumb: (data: AdminOrdersRes) => + `Order #${data.order.display_id}`, + }, }, ], }, diff --git a/packages/admin-next/dashboard/src/routes/orders/details/details.tsx b/packages/admin-next/dashboard/src/routes/orders/details/details.tsx deleted file mode 100644 index 24ef1a5830..0000000000 --- a/packages/admin-next/dashboard/src/routes/orders/details/details.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export const OrderDetails = () => { - return
Order Details
; -}; diff --git a/packages/admin-next/dashboard/src/routes/orders/details/index.ts b/packages/admin-next/dashboard/src/routes/orders/details/index.ts deleted file mode 100644 index 0e5b01fb73..0000000000 --- a/packages/admin-next/dashboard/src/routes/orders/details/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { OrderDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/index.ts new file mode 100644 index 0000000000..84cacaaaed --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/index.ts @@ -0,0 +1 @@ +export * from "./order-customer-section" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/order-customer-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/order-customer-section.tsx new file mode 100644 index 0000000000..b941bf80b3 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-customer-section/order-customer-section.tsx @@ -0,0 +1,319 @@ +import { Address as MedusaAddress, Order } from "@medusajs/medusa" +import { Avatar, Container, Copy, Heading, Text } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" + +import { ArrowPath, CurrencyDollar, Envelope, FlyingBox } from "@medusajs/icons" +import { ActionMenu } from "../../../../../components/common/action-menu" + +type OrderCustomerSectionProps = { + order: Order +} + +export const OrderCustomerSection = ({ order }: OrderCustomerSectionProps) => { + return ( + +
+ + + + + + ) +} + +const Header = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + return ( +
+ {t("fields.customer")} + , + }, + ], + }, + { + actions: [ + { + label: t("orders.customer.editShippingAddress"), + to: `#`, // TODO: Open modal to edit shipping address + icon: , + }, + { + label: t("orders.customer.editBillingAddress"), + to: `#`, // TODO: Open modal to edit billing address + icon: , + }, + ], + }, + { + actions: [ + { + label: t("orders.customer.editEmail"), + to: `#`, // TODO: Open modal to edit email + icon: , + }, + ], + }, + ]} + /> +
+ ) +} + +const getCustomerName = (order: Order) => { + const { first_name: sFirstName, last_name: sLastName } = + order.shipping_address || {} + const { first_name: bFirstName, last_name: bLastName } = + order.billing_address || {} + const { first_name: cFirstName, last_name: cLastName } = order.customer || {} + + const customerName = [cFirstName, cLastName].filter(Boolean).join(" ") + const shippingName = [sFirstName, sLastName].filter(Boolean).join(" ") + const billingName = [bFirstName, bLastName].filter(Boolean).join(" ") + + const name = customerName || shippingName || billingName + + return name +} + +const ID = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + const id = order.customer_id + const name = getCustomerName(order) + const email = order.email + const fallback = (name || email).charAt(0).toUpperCase() + + return ( +
+ + {t("fields.id")} + + +
+ + + {name || email} + +
+ +
+ ) +} + +const Company = ({ order }: { order: Order }) => { + const { t } = useTranslation() + const company = + order.shipping_address?.company || order.billing_address?.company + + if (!company) { + return null + } + + return ( +
+ + {t("fields.company")} + + + {company} + +
+ ) +} + +const Contact = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + const phone = order.shipping_address?.phone || order.billing_address?.phone + const email = order.email + + return ( +
+ + {t("orders.customer.contactLabel")} + +
+
+ + {email} + + +
+ +
+
+ {phone && ( +
+ + {phone} + + +
+ +
+
+ )} +
+
+ ) +} + +const isSameAddress = (a: MedusaAddress | null, b: MedusaAddress | null) => { + if (!a || !b) { + return false + } + + return ( + a.first_name === b.first_name && + a.last_name === b.last_name && + a.address_1 === b.address_1 && + a.address_2 === b.address_2 && + a.city === b.city && + a.postal_code === b.postal_code && + a.province === b.province && + a.country_code === b.country_code + ) +} + +const getFormattedAddress = ({ address }: { address: MedusaAddress }) => { + const { + first_name, + last_name, + company, + address_1, + address_2, + city, + postal_code, + province, + country, + } = address + + const name = [first_name, last_name].filter(Boolean).join(" ") + + const formattedAddress = [] + + if (name) { + formattedAddress.push(name) + } + + if (company) { + formattedAddress.push(company) + } + + if (address_1) { + formattedAddress.push(address_1) + } + + if (address_2) { + formattedAddress.push(address_2) + } + + const cityProvincePostal = [city, province, postal_code] + .filter(Boolean) + .join(" ") + + if (cityProvincePostal) { + formattedAddress.push(cityProvincePostal) + } + + if (country) { + formattedAddress.push(country.display_name) + } + + return formattedAddress +} + +const Address = ({ + address, + type, +}: { + address: MedusaAddress | null + type: "shipping" | "billing" +}) => { + const { t } = useTranslation() + + return ( +
+ + {type === "shipping" + ? t("addresses.shippingAddress") + : t("addresses.billingAddress")} + + {address ? ( +
+ + {getFormattedAddress({ address }).map((line, i) => { + return ( + + {line} +
+
+ ) + })} +
+
+ +
+
+ ) : ( + + - + + )} +
+ ) +} + +const Addresses = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + return ( +
+
+ {!isSameAddress(order.shipping_address, order.billing_address) ? ( +
+ ) : ( +
+ + {t("addresses.billingAddress")} + + + {t("addresses.sameAsShipping")} + +
+ )} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/index.ts new file mode 100644 index 0000000000..6945fbefaf --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/index.ts @@ -0,0 +1 @@ +export * from "./order-fulfillment-section" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx new file mode 100644 index 0000000000..0e2b878b43 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-fulfillment-section/order-fulfillment-section.tsx @@ -0,0 +1,314 @@ +import { XCircle } from "@medusajs/icons" +import { + LineItem, + Fulfillment as MedusaFulfillment, + Order, +} from "@medusajs/medusa" +import { + Container, + Copy, + Heading, + StatusBadge, + Text, + Tooltip, + usePrompt, +} from "@medusajs/ui" +import { format } from "date-fns" +import { useAdminCancelFulfillment, useAdminStockLocation } from "medusa-react" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { Skeleton } from "../../../../../components/common/skeleton" +import { Thumbnail } from "../../../../../components/common/thumbnail" +import { getLocaleAmount } from "../../../../../lib/money-amount-helpers" + +type OrderFulfillmentSectionProps = { + order: Order +} + +export const OrderFulfillmentSection = ({ + order, +}: OrderFulfillmentSectionProps) => { + const fulfillments = order.fulfillments || [] + + return ( +
+ + {fulfillments.map((f, index) => ( + + ))} +
+ ) +} + +const UnfulfilledItem = ({ + item, + currencyCode, +}: { + item: LineItem + currencyCode: string +}) => { + return ( +
+
+ +
+ + {item.title} + + {item.variant.sku && ( +
+ {item.variant.sku} + +
+ )} + + {item.variant.options.map((o) => o.value).join(" · ")} + +
+
+
+
+ + {getLocaleAmount(item.unit_price, currencyCode)} + +
+
+ + {item.quantity}x + +
+
+ + {getLocaleAmount(item.subtotal || 0, currencyCode)} + +
+
+
+ ) +} + +const UnfulfilledItemBreakdown = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + const fulfillmentItems = order.fulfillments?.map((f) => + f.items.map((i) => ({ id: i.item_id, quantity: i.quantity })) + ) + + // Create an array of order items that haven't been fulfilled or at least not fully fulfilled + const unfulfilledItems = order.items.filter( + (i) => + !fulfillmentItems?.some((fi) => + fi.some((f) => f.id === i.id && f.quantity === i.quantity) + ) + ) + + if (!unfulfilledItems.length) { + return null + } + + return ( + +
+ {t("orders.fulfillment.unfulfilledItems")} +
+ + {t("orders.fulfillment.awaitingFullfillmentBadge")} + + +
+
+
+ {unfulfilledItems.map((item) => ( + + ))} +
+
+ ) +} + +const Fulfillment = ({ + fulfillment, + order, + index, +}: { + fulfillment: MedusaFulfillment + order: Order + index: number +}) => { + const { t } = useTranslation() + const prompt = usePrompt() + + const showLocation = !!fulfillment.location_id + + const { stock_location, isError, error } = useAdminStockLocation( + fulfillment.location_id!, + { + enabled: showLocation, + } + ) + + let statusText = "Fulfilled" + let statusColor: "orange" | "green" | "red" = "orange" + let statusTimestamp = fulfillment.created_at + + if (fulfillment.canceled_at) { + statusText = "Canceled" + statusColor = "red" + statusTimestamp = fulfillment.canceled_at + } else if (fulfillment.shipped_at) { + statusText = "Shipped" + statusColor = "green" + statusTimestamp = fulfillment.shipped_at + } + + const { mutateAsync } = useAdminCancelFulfillment(order.id) + + const handleCancel = async () => { + if (fulfillment.shipped_at) { + // TODO: When we have implemented Toasts we should show an error toast here + return + } + + const res = await prompt({ + title: t("general.areYouSure"), + description: t("orders.fulfillment.cancelWarning"), + confirmText: t("actions.continue"), + cancelText: t("actions.cancel"), + }) + + if (res) { + await mutateAsync(fulfillment.id) + } + } + + if (isError) { + throw error + } + + return ( + +
+ + {t("orders.fulfillment.number", { + number: index + 1, + })} + +
+ + + {statusText} + + + , + onClick: handleCancel, + }, + ], + }, + ]} + /> +
+
+
+ + {t("orders.fulfillment.itemsLabel")} + +
    + {fulfillment.items.map((f_item) => ( +
  • + + {f_item.item.quantity}x {f_item.item.title} + +
  • + ))} +
+
+ {showLocation && ( +
+ + {t("orders.fulfillment.shippingFromLabel")} + + {stock_location ? ( + + + {stock_location.name} + + + ) : ( + + )} +
+ )} +
+ + {t("orders.fulfillment.trackingLabel")} + +
+ {fulfillment.tracking_links && + fulfillment.tracking_links.length > 0 ? ( +
    + {fulfillment.tracking_links.map((tlink) => { + const hasUrl = tlink.url && tlink.url.length > 0 + + if (hasUrl) { + return ( +
  • + + + {tlink.tracking_number} + + +
  • + ) + } + + return ( +
  • + + {tlink.tracking_number} + +
  • + ) + })} +
+ ) : ( + + - + + )} +
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/index.ts new file mode 100644 index 0000000000..583807cc1b --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/index.ts @@ -0,0 +1 @@ +export * from "./order-general-section" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/order-general-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/order-general-section.tsx new file mode 100644 index 0000000000..e6d50e2f7c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-general-section/order-general-section.tsx @@ -0,0 +1,132 @@ +import { XCircle } from "@medusajs/icons" +import { Order } from "@medusajs/medusa" +import { + Container, + Copy, + Heading, + StatusBadge, + Text, + usePrompt, +} from "@medusajs/ui" +import { format } from "date-fns" +import { useAdminCancelOrder } from "medusa-react" +import { useTranslation } from "react-i18next" +import { ActionMenu } from "../../../../../components/common/action-menu" + +type OrderGeneralSectionProps = { + order: Order +} + +export const OrderGeneralSection = ({ order }: OrderGeneralSectionProps) => { + const { t } = useTranslation() + const prompt = usePrompt() + + const { mutateAsync } = useAdminCancelOrder(order.id) + + const handleCancel = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("orders.cancelWarning", { + id: `#${order.display_id}`, + }), + confirmText: t("actions.continue"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync(undefined) + } + + return ( + +
+
+ #{order.display_id} + +
+ + {t("orders.onDateFromSalesChannel", { + date: format(new Date(order.created_at), "dd MMM, yyyy, HH:mm:ss"), + salesChannel: order.sales_channel.name, + })} + +
+
+
+ + +
+ , + }, + ], + }, + ]} + /> +
+
+ ) +} + +const FulfillmentBadge = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + const [label, color] = { + not_fulfilled: [t("orders.fulfillment.status.notFulfilled"), "red"], + partially_fulfilled: [ + t("orders.fulfillment.status.partiallyFulfilled"), + "orange", + ], + fulfilled: [t("orders.fulfillment.status.fulfilled"), "green"], + partially_shipped: [ + t("orders.fulfillment.status.partiallyShipped"), + "orange", + ], + shipped: [t("orders.fulfillment.status.shipped"), "green"], + partially_returned: [ + t("orders.fulfillment.status.partiallyReturned"), + "orange", + ], + returned: [t("orders.fulfillment.status.returned"), "green"], + canceled: [t("orders.fulfillment.status.canceled"), "red"], + requires_action: [t("orders.fulfillment.status.requresAction"), "orange"], + }[order.fulfillment_status] as [string, "red" | "orange" | "green"] + + return ( + + {label} + + ) +} + +const PaymentBadge = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + const [label, color] = { + not_paid: [t("orders.payment.status.notPaid"), "red"], + awaiting: [t("orders.payment.status.awaiting"), "orange"], + captured: [t("orders.payment.status.captured"), "green"], + refunded: [t("orders.payment.status.refunded"), "green"], + partially_refunded: [ + t("orders.payment.status.partiallyRefunded"), + "orange", + ], + canceled: [t("orders.payment.status.canceled"), "red"], + requires_action: [t("orders.payment.status.requresAction"), "orange"], + }[order.payment_status] as [string, "red" | "orange" | "green"] + + return ( + + {label} + + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-payment-section/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-payment-section/index.ts new file mode 100644 index 0000000000..4b0c66897c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-payment-section/index.ts @@ -0,0 +1 @@ +export * from "./order-payment-section" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-payment-section/order-payment-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-payment-section/order-payment-section.tsx new file mode 100644 index 0000000000..071056dbf3 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-payment-section/order-payment-section.tsx @@ -0,0 +1,280 @@ +import { ArrowDownRightMini, XCircle } from "@medusajs/icons" +import { + Payment as MedusaPayment, + Refund as MedusaRefund, + Order, +} from "@medusajs/medusa" +import { + Badge, + Button, + Container, + Heading, + StatusBadge, + Text, + Tooltip, +} from "@medusajs/ui" +import { format } from "date-fns" +import { useTranslation } from "react-i18next" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { + getLocaleAmount, + getStylizedAmount, +} from "../../../../../lib/money-amount-helpers" + +type OrderPaymentSectionProps = { + order: Order +} + +export const OrderPaymentSection = ({ order }: OrderPaymentSectionProps) => { + return ( + +
+ + + + ) +} + +const Header = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + return ( +
+ {t("orders.payment.title")} + , + to: "#", // TODO: Go to general refund modal + }, + ], + }, + ]} + /> +
+ ) +} + +const Refund = ({ + refund, + currencyCode, +}: { + refund: MedusaRefund + currencyCode: string +}) => { + const { t } = useTranslation() + const hasPayment = refund.payment_id !== null + + const BadgeComponent = ( + + {refund.reason} + + ) + + const Render = refund.note ? ( + {BadgeComponent} + ) : ( + BadgeComponent + ) + + return ( +
+
+ {hasPayment && } + + {t("orders.payment.refund")} + +
+
+ + {format(new Date(refund.created_at), "dd MMM, yyyy, HH:mm:ss")} + +
+
{Render}
+
+ + - {getLocaleAmount(refund.amount, currencyCode)} + +
+
+ ) +} + +const Payment = ({ + payment, + refunds, + currencyCode, +}: { + payment: MedusaPayment + refunds: MedusaRefund[] + currencyCode: string +}) => { + const { t } = useTranslation() + + const [status, color] = ( + payment.captured_at ? ["Captured", "green"] : ["Pending", "orange"] + ) as [string, "green" | "orange"] + + const cleanId = payment.id.replace("pay_", "") + const showCapture = + payment.captured_at === null && payment.canceled_at === null + + return ( +
+
+
+ + {cleanId} + + + {format(new Date(payment.created_at), "dd MMM, yyyy, HH:mm:ss")} + +
+
+ + {payment.provider_id} + +
+
+ + {status} + +
+
+ + {getLocaleAmount(payment.amount, payment.currency_code)} + +
+ , + to: "#", // TODO: Go to specific payment refund modal + }, + ], + }, + ]} + /> +
+ {showCapture && ( +
+
+ + + {t("orders.payment.isReadyToBeCaptured", { + id: cleanId, + })} + +
+ +
+ )} + {refunds.map((refund) => ( + + ))} +
+ ) +} + +const PaymentBreakdown = ({ + payments, + refunds, + currencyCode, +}: { + payments: MedusaPayment[] + refunds: MedusaRefund[] + currencyCode: string +}) => { + /** + * Refunds that are not associated with a payment. + */ + const orderRefunds = refunds.filter((refund) => refund.payment_id === null) + + const entries = [...orderRefunds, ...payments] + .sort((a, b) => { + return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + }) + .map((entry) => { + return { + event: entry, + type: entry.id.startsWith("pay_") ? "payment" : "refund", + } + }) as ( + | { type: "payment"; event: MedusaPayment } + | { type: "refund"; event: MedusaRefund } + )[] + + return ( +
+ {entries.map(({ type, event }) => { + switch (type) { + case "payment": + return ( + refund.payment_id === event.id + )} + currencyCode={currencyCode} + /> + ) + case "refund": + return ( + + ) + } + })} +
+ ) +} + +const Total = ({ + payments, + currencyCode, +}: { + payments: MedusaPayment[] + currencyCode: string +}) => { + const { t } = useTranslation() + + const paid = payments.reduce((acc, payment) => acc + payment.amount, 0) + const refunded = payments.reduce( + (acc, payment) => acc + (payment.amount_refunded || 0), + 0 + ) + + const total = paid - refunded + + return ( +
+ + {t("orders.payment.totalPaidByCustomer")} + + + {getStylizedAmount(total, currencyCode)} + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/index.ts new file mode 100644 index 0000000000..65fbe542a2 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/index.ts @@ -0,0 +1 @@ +export * from "./order-summary-section" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx new file mode 100644 index 0000000000..ec6e68a607 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/components/order-summary-section/order-summary-section.tsx @@ -0,0 +1,239 @@ +import { Buildings, PencilSquare } from "@medusajs/icons" +import { LineItem, Order } from "@medusajs/medusa" +import { ReservationItemDTO } from "@medusajs/types" +import { Container, Copy, Heading, StatusBadge, Text } from "@medusajs/ui" +import { useAdminReservations } from "medusa-react" +import { useTranslation } from "react-i18next" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { Thumbnail } from "../../../../../components/common/thumbnail" +import { + getLocaleAmount, + getStylizedAmount, +} from "../../../../../lib/money-amount-helpers" + +type OrderSummarySectionProps = { + order: Order +} + +export const OrderSummarySection = ({ order }: OrderSummarySectionProps) => { + return ( + +
+ + + + + ) +} + +const Header = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + return ( +
+ {t("fields.summary")} + , + }, + { + label: t("orders.summary.allocateItems"), + to: "#", // TODO: Open modal to allocate items + icon: , + }, + ], + }, + ]} + /> +
+ ) +} + +const Item = ({ + item, + currencyCode, + reservation, +}: { + item: LineItem + currencyCode: string + reservation?: ReservationItemDTO | null +}) => { + const { t } = useTranslation() + + return ( +
+
+ +
+ + {item.title} + + {item.variant.sku && ( +
+ {item.variant.sku} + +
+ )} + + {item.variant.options.map((o) => o.value).join(" · ")} + +
+
+
+
+ + {getLocaleAmount(item.unit_price, currencyCode)} + +
+
+
+ + {item.quantity}x + +
+
+ + {reservation + ? t("orders.reservations.allocatedLabel") + : t("orders.reservations.notAllocatedLabel")} + +
+
+
+ + {getLocaleAmount(item.subtotal || 0, currencyCode)} + +
+
+
+ ) +} + +const ItemBreakdown = ({ order }: { order: Order }) => { + const { reservations, isError, error } = useAdminReservations({ + line_item_id: order.items.map((i) => i.id), + }) + + if (isError) { + throw error + } + + return ( +
+ {order.items.map((item) => { + const reservation = reservations + ? reservations.find((r) => r.line_item_id === item.id) + : null + + return ( + + ) + })} +
+ ) +} + +const Cost = ({ + label, + value, + secondaryValue, +}: { + label: string + value: string | number + secondaryValue: string +}) => ( +
+ + {label} + +
+ + {secondaryValue} + +
+
+ + {value} + +
+
+) + +const CostBreakdown = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + return ( +
+ + 0 + ? order.discounts.map((d) => d.code).join(", ") + : "-" + } + value={ + order.discount_total > 0 + ? `- ${getLocaleAmount(order.discount_total, order.currency_code)}` + : "-" + } + /> + sm.shipping_option.name) + .join(", ")} + value={getLocaleAmount(order.shipping_total, order.currency_code)} + /> + +
+ ) +} + +const Total = ({ order }: { order: Order }) => { + const { t } = useTranslation() + + return ( +
+ + {t("fields.total")} + + + {getStylizedAmount(order.total, order.currency_code)} + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/constants.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/constants.ts new file mode 100644 index 0000000000..bd7e8fe093 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/constants.ts @@ -0,0 +1,2 @@ +export const orderExpand = + "items,items.variant,items.variant.options,sales_channel,shipping_methods,shipping_methods.shipping_option,discounts,payments,customer,shipping_address,shipping_address.country,billing_address,billing_address.country,fulfillments,fulfillments.items,fulfillments.items.item,fulfillments.tracking_links,refunds" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/index.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/index.ts new file mode 100644 index 0000000000..72ffee80e5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/index.ts @@ -0,0 +1,2 @@ +export { orderLoader as loader } from "./loader" +export { OrderDetail as Component } from "./order-detail" diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/loader.ts b/packages/admin-next/dashboard/src/routes/orders/order-detail/loader.ts new file mode 100644 index 0000000000..9230da099e --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/loader.ts @@ -0,0 +1,25 @@ +import { AdminOrdersRes } from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { adminOrderKeys } from "medusa-react" +import { LoaderFunctionArgs } from "react-router-dom" + +import { medusa, queryClient } from "../../../lib/medusa" +import { orderExpand } from "./constants" + +const orderDetailQuery = (id: string) => ({ + queryKey: adminOrderKeys.detail(id), + queryFn: async () => + medusa.admin.orders.retrieve(id, { + expand: orderExpand, + }), +}) + +export const orderLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = orderDetailQuery(id!) + + return ( + queryClient.getQueryData>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx b/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx new file mode 100644 index 0000000000..108b7f5939 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/orders/order-detail/order-detail.tsx @@ -0,0 +1,52 @@ +import { useAdminOrder } from "medusa-react" +import { useLoaderData, useParams } from "react-router-dom" +import { JsonViewSection } from "../../../components/common/json-view-section" +import { OrderCustomerSection } from "./components/order-customer-section" +import { OrderFulfillmentSection } from "./components/order-fulfillment-section" +import { OrderGeneralSection } from "./components/order-general-section" +import { OrderPaymentSection } from "./components/order-payment-section" +import { OrderSummarySection } from "./components/order-summary-section" +import { orderExpand } from "./constants" +import { orderLoader } from "./loader" + +export const OrderDetail = () => { + const initialData = useLoaderData() as Awaited> + + const { id } = useParams() + + const { order, isLoading, isError, error } = useAdminOrder( + id!, + { + expand: orderExpand, + }, + { + initialData, + } + ) + + if (isLoading || !order) { + return
Loading...
+ } + + if (isError) { + throw error + } + + return ( +
+
+ + + + +
+ +
+ +
+
+ +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx b/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx index 04274e1863..c2716f1840 100644 --- a/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx +++ b/packages/admin-next/dashboard/src/routes/orders/order-list/components/order-list-table/order-list-table.tsx @@ -8,7 +8,8 @@ import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-t import { useDataTable } from "../../../../../hooks/use-data-table" const PAGE_SIZE = 20 -const DEFAULT_RELATIONS = "customer,items,sales_channel" +const DEFAULT_RELATIONS = + "customer,items,sales_channel,shipping_address,shipping_address.country" const DEFAULT_FIELDS = "id,status,display_id,created_at,email,fulfillment_status,payment_status,total,currency_code" diff --git a/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx b/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx index b823d9c715..903819b97d 100644 --- a/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/products/product-detail/product-detail.tsx @@ -47,13 +47,13 @@ export const ProductDetail = () => { ) })} -
+
-
+
{sideBefore.widgets.map((w, i) => { return (
@@ -81,7 +81,7 @@ export const ProductDetail = () => { })}
-
+
{sideBefore.widgets.map((w, i) => { return (
diff --git a/yarn.lock b/yarn.lock index 9649071ecf..cc63a681b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8170,6 +8170,7 @@ __metadata: postcss: ^8.4.33 prettier: ^3.1.1 react: 18.2.0 + react-country-flag: ^3.1.0 react-dom: 18.2.0 react-focus-lock: ^2.11.1 react-hook-form: 7.49.1 @@ -43584,7 +43585,7 @@ __metadata: languageName: node linkType: hard -"react-country-flag@npm:^3.0.2": +"react-country-flag@npm:^3.0.2, react-country-flag@npm:^3.1.0": version: 3.1.0 resolution: "react-country-flag@npm:3.1.0" peerDependencies: