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.
This commit is contained in:
Kasper Fabricius Kristensen
2024-03-05 14:06:50 +01:00
committed by GitHub
parent 62a7bcc30c
commit e5dc918be5
33 changed files with 1620 additions and 128 deletions

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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 <PlaceholderCell />
}
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 (
<div
@@ -37,9 +26,7 @@ export const MoneyAmountCell = ({
"justify-end text-right": align === "right",
})}
>
<span className="truncate">
{symbol} {formattedTotal} {currencyCode.toUpperCase()}
</span>
<span className="truncate">{formatted}</span>
</div>
)
}

View File

@@ -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 <PlaceholderCell />
}
return (
<div className="flex size-5 items-center justify-center">
<Tooltip content={country.display_name}>
<div className="flex size-4 items-center justify-center overflow-hidden rounded-sm">
<ReactCountryFlag
countryCode={country.iso_2.toUpperCase()}
svg
style={{
width: "16px",
height: "16px",
}}
aria-label={country.display_name}
/>
</div>
</Tooltip>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./country-cell"

View File

@@ -11,9 +11,7 @@ export const CustomerCell = ({ customer }: { customer: Customer | null }) => {
return (
<div className="flex h-full w-full items-center">
<div>
<span className="truncate">{name || email}</span>
</div>
<span className="truncate">{name || email}</span>
</div>
)
}

View File

@@ -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 <StatusCell color={color}>{label}</StatusCell>

View File

@@ -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 (
<div className="flex h-full w-full items-center overflow-hidden">
<span className="truncate">
{t("general.items", {
count: items.length,
})}
</span>
</div>
)
}
export const ItemsHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center">
<span className="truncate">{t("fields.items")}</span>
</div>
)
}

View File

@@ -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 <StatusCell color={color}>{label}</StatusCell>

View File

@@ -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 <DateCell date={date} />
},
}),
columnHelper.accessor("customer", {
header: () => <CustomerHeader />,
cell: ({ getValue }) => {
const customer = getValue()
return <CustomerCell customer={customer} />
},
}),
columnHelper.accessor("sales_channel", {
header: () => <SalesChannelHeader />,
cell: ({ getValue }) => {
@@ -86,14 +95,6 @@ export const useOrderTableColumns = (props: UseOrderTableColumnsProps) => {
return <FulfillmentStatusCell status={status} />
},
}),
columnHelper.accessor("items", {
header: () => <ItemsHeader />,
cell: ({ getValue }) => {
const items = getValue()
return <ItemsCell items={items} />
},
}),
columnHelper.accessor("total", {
header: () => <TotalHeader />,
cell: ({ getValue, row }) => {
@@ -103,6 +104,14 @@ export const useOrderTableColumns = (props: UseOrderTableColumnsProps) => {
return <TotalCell currencyCode={currencyCode} total={total} />
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => {
const country = row.original.shipping_address?.country
return <CountryCell country={country} />
},
}),
],
[]
)

View File

@@ -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",
},
],

View File

@@ -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()}`
}

View File

@@ -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}`,
},
},
],
},

View File

@@ -1,3 +0,0 @@
export const OrderDetails = () => {
return <div>Order Details</div>;
};

View File

@@ -1 +0,0 @@
export { OrderDetails as Component } from "./details";

View File

@@ -0,0 +1 @@
export * from "./order-customer-section"

View File

@@ -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 (
<Container className="divide-y p-0">
<Header order={order} />
<ID order={order} />
<Contact order={order} />
<Company order={order} />
<Addresses order={order} />
</Container>
)
}
const Header = ({ order }: { order: Order }) => {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("fields.customer")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("orders.customer.transferOwnership"),
to: `#`, // TODO: Open modal to transfer ownership
icon: <ArrowPath />,
},
],
},
{
actions: [
{
label: t("orders.customer.editShippingAddress"),
to: `#`, // TODO: Open modal to edit shipping address
icon: <FlyingBox />,
},
{
label: t("orders.customer.editBillingAddress"),
to: `#`, // TODO: Open modal to edit billing address
icon: <CurrencyDollar />,
},
],
},
{
actions: [
{
label: t("orders.customer.editEmail"),
to: `#`, // TODO: Open modal to edit email
icon: <Envelope />,
},
],
},
]}
/>
</div>
)
}
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 (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.id")}
</Text>
<Link
to={`/customers/${id}`}
className="focus:shadow-borders-focus rounded-[4px] outline-none transition-shadow"
>
<div className="flex items-center gap-x-2 overflow-hidden">
<Avatar size="2xsmall" fallback={fallback} />
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle hover:text-ui-fg-base transition-fg truncate"
>
{name || email}
</Text>
</div>
</Link>
</div>
)
}
const Company = ({ order }: { order: Order }) => {
const { t } = useTranslation()
const company =
order.shipping_address?.company || order.billing_address?.company
if (!company) {
return null
}
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.company")}
</Text>
<Text size="small" leading="compact" className="truncate">
{company}
</Text>
</div>
)
}
const Contact = ({ order }: { order: Order }) => {
const { t } = useTranslation()
const phone = order.shipping_address?.phone || order.billing_address?.phone
const email = order.email
return (
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("orders.customer.contactLabel")}
</Text>
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
<Text
size="small"
leading="compact"
className="text-pretty break-all"
>
{email}
</Text>
<div className="flex justify-end">
<Copy content={email} className="text-ui-fg-muted" />
</div>
</div>
{phone && (
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
<Text
size="small"
leading="compact"
className="text-pretty break-all"
>
{phone}
</Text>
<div className="flex justify-end">
<Copy content={email} className="text-ui-fg-muted" />
</div>
</div>
)}
</div>
</div>
)
}
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 (
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{type === "shipping"
? t("addresses.shippingAddress")
: t("addresses.billingAddress")}
</Text>
{address ? (
<div className="grid grid-cols-[1fr_20px] items-start gap-x-2">
<Text size="small" leading="compact">
{getFormattedAddress({ address }).map((line, i) => {
return (
<span key={i} className="break-words">
{line}
<br />
</span>
)
})}
</Text>
<div className="flex justify-end">
<Copy
content={getFormattedAddress({ address }).join("\n")}
className="text-ui-fg-muted"
/>
</div>
</div>
) : (
<Text size="small" leading="compact">
-
</Text>
)}
</div>
)
}
const Addresses = ({ order }: { order: Order }) => {
const { t } = useTranslation()
return (
<div className="divide-y">
<Address address={order.shipping_address} type="shipping" />
{!isSameAddress(order.shipping_address, order.billing_address) ? (
<Address address={order.billing_address} type="billing" />
) : (
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-subtle"
>
{t("addresses.billingAddress")}
</Text>
<Text size="small" leading="compact" className="text-ui-fg-muted">
{t("addresses.sameAsShipping")}
</Text>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./order-fulfillment-section"

View File

@@ -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 (
<div className="flex flex-col gap-y-2">
<UnfulfilledItemBreakdown order={order} />
{fulfillments.map((f, index) => (
<Fulfillment key={f.id} index={index} order={order} fulfillment={f} />
))}
</div>
)
}
const UnfulfilledItem = ({
item,
currencyCode,
}: {
item: LineItem
currencyCode: string
}) => {
return (
<div
key={item.id}
className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4"
>
<div className="flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-base"
>
{item.title}
</Text>
{item.variant.sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.variant.sku}</Text>
<Copy content={item.variant.sku} className="text-ui-fg-muted" />
</div>
)}
<Text size="small">
{item.variant.options.map((o) => o.value).join(" · ")}
</Text>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-x-4">
<div className="flex items-center justify-end">
<Text size="small">
{getLocaleAmount(item.unit_price, currencyCode)}
</Text>
</div>
<div className="flex items-center justify-end">
<Text>
<span className="tabular-nums">{item.quantity}</span>x
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small">
{getLocaleAmount(item.subtotal || 0, currencyCode)}
</Text>
</div>
</div>
</div>
)
}
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 (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("orders.fulfillment.unfulfilledItems")}</Heading>
<div className="flex items-center gap-x-4">
<StatusBadge color="red" className="text-nowrap">
{t("orders.fulfillment.awaitingFullfillmentBadge")}
</StatusBadge>
<ActionMenu groups={[]} />
</div>
</div>
<div>
{unfulfilledItems.map((item) => (
<UnfulfilledItem
key={item.id}
item={item}
currencyCode={order.currency_code}
/>
))}
</div>
</Container>
)
}
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 (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">
{t("orders.fulfillment.number", {
number: index + 1,
})}
</Heading>
<div className="flex items-center gap-x-4">
<Tooltip
content={format(
new Date(statusTimestamp),
"dd MMM, yyyy, HH:mm:ss"
)}
>
<StatusBadge color={statusColor} className="text-nowrap">
{statusText}
</StatusBadge>
</Tooltip>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.cancel"),
icon: <XCircle />,
onClick: handleCancel,
},
],
},
]}
/>
</div>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("orders.fulfillment.itemsLabel")}
</Text>
<ul>
{fulfillment.items.map((f_item) => (
<li key={f_item.item_id}>
<Text size="small" leading="compact">
{f_item.item.quantity}x {f_item.item.title}
</Text>
</li>
))}
</ul>
</div>
{showLocation && (
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("orders.fulfillment.shippingFromLabel")}
</Text>
{stock_location ? (
<Link
to={`/settings/locations/${stock_location.id}`}
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover transition-fg"
>
<Text size="small" leading="compact">
{stock_location.name}
</Text>
</Link>
) : (
<Skeleton className="w-16" />
)}
</div>
)}
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("orders.fulfillment.trackingLabel")}
</Text>
<div>
{fulfillment.tracking_links &&
fulfillment.tracking_links.length > 0 ? (
<ul>
{fulfillment.tracking_links.map((tlink) => {
const hasUrl = tlink.url && tlink.url.length > 0
if (hasUrl) {
return (
<li key={tlink.tracking_number}>
<a
href={tlink.url}
target="_blank"
rel="noopener noreferrer"
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover transition-fg"
>
<Text size="small" leading="compact">
{tlink.tracking_number}
</Text>
</a>
</li>
)
}
return (
<li key={tlink.tracking_number}>
<Text size="small" leading="compact">
{tlink.tracking_number}
</Text>
</li>
)
})}
</ul>
) : (
<Text size="small" leading="compact">
-
</Text>
)}
</div>
</div>
</Container>
)
}

View File

@@ -0,0 +1 @@
export * from "./order-general-section"

View File

@@ -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 (
<Container className="flex items-center justify-between px-6 py-4">
<div>
<div className="flex items-center gap-x-1">
<Heading>#{order.display_id}</Heading>
<Copy content={`#${order.display_id}`} className="text-ui-fg-muted" />
</div>
<Text size="small" className="text-ui-fg-subtle">
{t("orders.onDateFromSalesChannel", {
date: format(new Date(order.created_at), "dd MMM, yyyy, HH:mm:ss"),
salesChannel: order.sales_channel.name,
})}
</Text>
</div>
<div className="flex items-center gap-x-4">
<div className="flex items-center gap-x-1.5">
<PaymentBadge order={order} />
<FulfillmentBadge order={order} />
</div>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.cancel"),
onClick: handleCancel,
icon: <XCircle />,
},
],
},
]}
/>
</div>
</Container>
)
}
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 (
<StatusBadge color={color} className="text-nowrap">
{label}
</StatusBadge>
)
}
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 (
<StatusBadge color={color} className="text-nowrap">
{label}
</StatusBadge>
)
}

View File

@@ -0,0 +1 @@
export * from "./order-payment-section"

View File

@@ -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 (
<Container className="divide-y divide-dashed p-0">
<Header order={order} />
<PaymentBreakdown
payments={order.payments}
refunds={order.refunds}
currencyCode={order.currency_code}
/>
<Total payments={order.payments} currencyCode={order.currency_code} />
</Container>
)
}
const Header = ({ order }: { order: Order }) => {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("orders.payment.title")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("orders.payment.refund"),
icon: <ArrowDownRightMini />,
to: "#", // TODO: Go to general refund modal
},
],
},
]}
/>
</div>
)
}
const Refund = ({
refund,
currencyCode,
}: {
refund: MedusaRefund
currencyCode: string
}) => {
const { t } = useTranslation()
const hasPayment = refund.payment_id !== null
const BadgeComponent = (
<Badge size="2xsmall" className="cursor-default select-none capitalize">
{refund.reason}
</Badge>
)
const Render = refund.note ? (
<Tooltip content={refund.note}>{BadgeComponent}</Tooltip>
) : (
BadgeComponent
)
return (
<div className="bg-ui-bg-subtle text-ui-fg-subtle grid grid-cols-[1fr_1fr_1fr_1fr_20px] items-center gap-x-4 px-6 py-4">
<div>
{hasPayment && <ArrowDownRightMini className="text-ui-fg-muted" />}
<Text size="small" leading="compact" weight="plus">
{t("orders.payment.refund")}
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
{format(new Date(refund.created_at), "dd MMM, yyyy, HH:mm:ss")}
</Text>
</div>
<div className="flex items-center justify-end">{Render}</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
- {getLocaleAmount(refund.amount, currencyCode)}
</Text>
</div>
</div>
)
}
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 (
<div className="divide-y divide-dashed">
<div className="text-ui-fg-subtle grid grid-cols-[1fr_1fr_1fr_1fr_20px] items-center gap-x-4 px-6 py-4">
<div className="w-full overflow-hidden">
<Text
size="small"
leading="compact"
weight="plus"
className="truncate"
>
{cleanId}
</Text>
<Text size="small" leading="compact">
{format(new Date(payment.created_at), "dd MMM, yyyy, HH:mm:ss")}
</Text>
</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact" className="capitalize">
{payment.provider_id}
</Text>
</div>
<div className="flex items-center justify-end">
<StatusBadge color={color} className="text-nowrap">
{status}
</StatusBadge>
</div>
<div className="flex items-center justify-end">
<Text size="small" leading="compact">
{getLocaleAmount(payment.amount, payment.currency_code)}
</Text>
</div>
<ActionMenu
groups={[
{
actions: [
{
label: t("orders.payment.refund"),
icon: <XCircle />,
to: "#", // TODO: Go to specific payment refund modal
},
],
},
]}
/>
</div>
{showCapture && (
<div className="bg-ui-bg-subtle flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-x-2">
<ArrowDownRightMini className="text-ui-fg-muted" />
<Text size="small" leading="compact">
{t("orders.payment.isReadyToBeCaptured", {
id: cleanId,
})}
</Text>
</div>
<Button size="small" variant="secondary">
{t("orders.payment.capture")}
</Button>
</div>
)}
{refunds.map((refund) => (
<Refund key={refund.id} refund={refund} currencyCode={currencyCode} />
))}
</div>
)
}
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 (
<div className="flex flex-col divide-y divide-dashed">
{entries.map(({ type, event }) => {
switch (type) {
case "payment":
return (
<Payment
key={event.id}
payment={event}
refunds={refunds.filter(
(refund) => refund.payment_id === event.id
)}
currencyCode={currencyCode}
/>
)
case "refund":
return (
<Refund
key={event.id}
refund={event}
currencyCode={currencyCode}
/>
)
}
})}
</div>
)
}
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 (
<div className="flex items-center justify-between px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("orders.payment.totalPaidByCustomer")}
</Text>
<Text size="small" weight="plus" leading="compact">
{getStylizedAmount(total, currencyCode)}
</Text>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./order-summary-section"

View File

@@ -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 (
<Container className="divide-y divide-dashed p-0">
<Header order={order} />
<ItemBreakdown order={order} />
<CostBreakdown order={order} />
<Total order={order} />
</Container>
)
}
const Header = ({ order }: { order: Order }) => {
const { t } = useTranslation()
return (
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("fields.summary")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("orders.summary.editItems"),
to: "#", // TODO: Open modal to edit items
icon: <PencilSquare />,
},
{
label: t("orders.summary.allocateItems"),
to: "#", // TODO: Open modal to allocate items
icon: <Buildings />,
},
],
},
]}
/>
</div>
)
}
const Item = ({
item,
currencyCode,
reservation,
}: {
item: LineItem
currencyCode: string
reservation?: ReservationItemDTO | null
}) => {
const { t } = useTranslation()
return (
<div
key={item.id}
className="text-ui-fg-subtle grid grid-cols-2 items-start gap-x-4 px-6 py-4"
>
<div className="flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-base"
>
{item.title}
</Text>
{item.variant.sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.variant.sku}</Text>
<Copy content={item.variant.sku} className="text-ui-fg-muted" />
</div>
)}
<Text size="small">
{item.variant.options.map((o) => o.value).join(" · ")}
</Text>
</div>
</div>
<div className="grid grid-cols-3 items-center gap-x-4">
<div className="flex items-center justify-end gap-x-4">
<Text size="small">
{getLocaleAmount(item.unit_price, currencyCode)}
</Text>
</div>
<div className="flex items-center gap-x-2">
<div className="w-fit min-w-[27px]">
<Text>
<span className="tabular-nums">{item.quantity}</span>x
</Text>
</div>
<div className="overflow-visible">
<StatusBadge
color={reservation ? "green" : "orange"}
className="text-nowrap"
>
{reservation
? t("orders.reservations.allocatedLabel")
: t("orders.reservations.notAllocatedLabel")}
</StatusBadge>
</div>
</div>
<div className="flex items-center justify-end">
<Text size="small">
{getLocaleAmount(item.subtotal || 0, currencyCode)}
</Text>
</div>
</div>
</div>
)
}
const ItemBreakdown = ({ order }: { order: Order }) => {
const { reservations, isError, error } = useAdminReservations({
line_item_id: order.items.map((i) => i.id),
})
if (isError) {
throw error
}
return (
<div>
{order.items.map((item) => {
const reservation = reservations
? reservations.find((r) => r.line_item_id === item.id)
: null
return (
<Item
key={item.id}
item={item}
currencyCode={order.currency_code}
reservation={reservation}
/>
)
})}
</div>
)
}
const Cost = ({
label,
value,
secondaryValue,
}: {
label: string
value: string | number
secondaryValue: string
}) => (
<div className="grid grid-cols-3 items-center">
<Text size="small" leading="compact">
{label}
</Text>
<div className="text-right">
<Text size="small" leading="compact">
{secondaryValue}
</Text>
</div>
<div className="text-right">
<Text size="small" leading="compact">
{value}
</Text>
</div>
</div>
)
const CostBreakdown = ({ order }: { order: Order }) => {
const { t } = useTranslation()
return (
<div className="text-ui-fg-subtle flex flex-col gap-y-2 px-6 py-4">
<Cost
label={t("fields.subtotal")}
secondaryValue={t("general.items", { count: order.items.length })}
value={getLocaleAmount(order.subtotal, order.currency_code)}
/>
<Cost
label={t("fields.discount")}
secondaryValue={
order.discounts.length > 0
? order.discounts.map((d) => d.code).join(", ")
: "-"
}
value={
order.discount_total > 0
? `- ${getLocaleAmount(order.discount_total, order.currency_code)}`
: "-"
}
/>
<Cost
label={t("fields.shipping")}
secondaryValue={order.shipping_methods
.map((sm) => sm.shipping_option.name)
.join(", ")}
value={getLocaleAmount(order.shipping_total, order.currency_code)}
/>
<Cost
label={t("fields.tax")}
secondaryValue={`${order.tax_rate || 0}%`}
value={
order.tax_total
? getLocaleAmount(order.tax_total, order.currency_code)
: "-"
}
/>
</div>
)
}
const Total = ({ order }: { order: Order }) => {
const { t } = useTranslation()
return (
<div className="text-ui-fg-base flex items-center justify-between px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.total")}
</Text>
<Text size="small" leading="compact" weight="plus">
{getStylizedAmount(order.total, order.currency_code)}
</Text>
</div>
)
}

View File

@@ -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"

View File

@@ -0,0 +1,2 @@
export { orderLoader as loader } from "./loader"
export { OrderDetail as Component } from "./order-detail"

View File

@@ -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<Response<AdminOrdersRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -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<ReturnType<typeof orderLoader>>
const { id } = useParams()
const { order, isLoading, isError, error } = useAdminOrder(
id!,
{
expand: orderExpand,
},
{
initialData,
}
)
if (isLoading || !order) {
return <div>Loading...</div>
}
if (isError) {
throw error
}
return (
<div className="grid grid-cols-1 gap-x-4 xl:grid-cols-[1fr,400px]">
<div className="flex flex-col gap-y-2">
<OrderGeneralSection order={order} />
<OrderSummarySection order={order} />
<OrderPaymentSection order={order} />
<OrderFulfillmentSection order={order} />
<div className="flex flex-col gap-y-2 lg:hidden">
<OrderCustomerSection order={order} />
</div>
<JsonViewSection data={order} />
</div>
<div className="hidden flex-col gap-y-2 lg:flex">
<OrderCustomerSection order={order} />
</div>
</div>
)
}

View File

@@ -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"

View File

@@ -47,13 +47,13 @@ export const ProductDetail = () => {
</div>
)
})}
<div className="grid grid-cols-1 gap-x-4 lg:grid-cols-[1fr,400px]">
<div className="grid grid-cols-1 gap-x-4 xl:grid-cols-[1fr,400px]">
<div className="flex flex-col gap-y-2">
<ProductGeneralSection product={product} />
<ProductMediaSection product={product} />
<ProductOptionSection product={product} />
<ProductVariantSection product={product} />
<div className="flex flex-col gap-y-2 lg:hidden">
<div className="flex flex-col gap-y-2 xl:hidden">
{sideBefore.widgets.map((w, i) => {
return (
<div key={i}>
@@ -81,7 +81,7 @@ export const ProductDetail = () => {
})}
<JsonViewSection data={product} root="product" />
</div>
<div className="hidden flex-col gap-y-2 lg:flex">
<div className="hidden flex-col gap-y-2 xl:flex">
{sideBefore.widgets.map((w, i) => {
return (
<div key={i}>

View File

@@ -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: