feat(dashboard): basic Order UI (#7225)

**WHAT**
- rudimentary list and details pages
- fix Order<>Promotion link
- fix Order<>SalesChannel link

**NOTE**
- displaying basic info since we don't have Fulfillments & Payments linked ATM
- `disaply_id` needs to be added to order
- `tax_rate` needs to be added to order

---


https://github.com/medusajs/medusa/assets/16856471/cd4e98c7-345e-4193-8c1e-ad4ed1584993



Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com>
This commit is contained in:
Frane Polić
2024-05-07 09:28:42 +02:00
committed by GitHub
parent 0430e63b0b
commit 39c3f6d92a
40 changed files with 1953 additions and 81 deletions

View File

@@ -19,6 +19,7 @@
"is": "is",
"timeline": "Timeline",
"success": "Success",
"warning": "Warning",
"error": "Error",
"select": "Select",
"selected": "Selected",
@@ -540,6 +541,10 @@
"canceled": "Canceled",
"requiresAction": "Requires action"
},
"toast": {
"canceled": "Fulfillment successfully canceled",
"fulfillmentShipped": "Cannot cancel an already shipped fulfillment"
},
"trackingLabel": "Tracking",
"shippingFromLabel": "Shipping from",
"itemsLabel": "Items"

View File

@@ -12,6 +12,11 @@ export const FulfillmentStatusCell = ({
}: FulfillmentStatusCellProps) => {
const { t } = useTranslation()
if (!status) {
// TODO: remove this once fulfillment<>order link is added
return "-"
}
const { label, color } = getOrderFulfillmentStatus(t, status)
return <StatusCell color={color}>{label}</StatusCell>

View File

@@ -10,6 +10,9 @@ type PaymentStatusCellProps = {
export const PaymentStatusCell = ({ status }: PaymentStatusCellProps) => {
const { t } = useTranslation()
// TODO: remove this when Order<>Payments are linked
return "-"
const { label, color } = getOrderPaymentStatus(t, status)
return <StatusCell color={color}>{label}</StatusCell>

View File

@@ -0,0 +1,40 @@
import { QueryKey, useQuery, UseQueryOptions } from "@tanstack/react-query"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { client } from "../../lib/client"
const ORDERS_QUERY_KEY = "orders" as const
export const ordersQueryKeys = queryKeysFactory(ORDERS_QUERY_KEY)
export const useOrder = (
id: string,
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.orders.retrieve(id, query),
queryKey: ordersQueryKeys.detail(id, query),
...options,
})
return { ...data, ...rest }
}
export const useOrders = (
query?: Record<string, any>,
options?: Omit<
UseQueryOptions<any, Error, any, QueryKey>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => client.orders.list(query),
queryKey: ordersQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -1,18 +1,19 @@
import { useAdminRegions, useAdminSalesChannels } from "medusa-react"
import { useTranslation } from "react-i18next"
import type { Filter } from "../../../components/table/data-table"
import { useRegions } from "../../api/regions"
import { useSalesChannels } from "../../api/sales-channels"
export const useOrderTableFilters = (): Filter[] => {
const { t } = useTranslation()
const { regions } = useAdminRegions({
const { regions } = useRegions({
limit: 1000,
fields: "id,name",
expand: "",
})
const { sales_channels } = useAdminSalesChannels({
const { sales_channels } = useSalesChannels({
limit: 1000,
fields: "id,name",
expand: "",
@@ -145,8 +146,9 @@ export const useOrderTableFilters = (): Filter[] => {
filters = [
...filters,
paymentStatusFilter,
fulfillmentStatusFilter,
// TODO: enable when Payment, Fulfillments <> Orders are linked
// paymentStatusFilter,
// fulfillmentStatusFilter,
...dateFilters,
]

View File

@@ -23,6 +23,7 @@ import { stores } from "./stores"
import { tags } from "./tags"
import { taxes } from "./taxes"
import { users } from "./users"
import { orders } from "./orders"
import { workflowExecutions } from "./workflow-executions"
import { shippingProfiles } from "./shipping-profiles"
@@ -43,6 +44,7 @@ export const client = {
shippingProfiles: shippingProfiles,
tags: tags,
users: users,
orders: orders,
regions: regions,
taxes: taxes,
invites: invites,

View File

@@ -0,0 +1,15 @@
import { getRequest } from "./common"
import { OrderListRes, OrderRes } from "../../types/api-responses"
async function retrieveOrder(id: string, query?: Record<string, any>) {
return getRequest<OrderRes, Record<string, any>>(`/admin/orders/${id}`, query)
}
async function listOrders(query?: Record<string, any>) {
return getRequest<OrderListRes, Record<string, any>>(`/admin/orders`, query)
}
export const orders = {
list: listOrders,
retrieve: retrieveOrder,
}

View File

@@ -5,7 +5,7 @@ export const getOrderPaymentStatus = (
t: TFunction<"translation">,
status: PaymentStatus
) => {
const [label, color] = {
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"],
@@ -16,7 +16,9 @@ export const getOrderPaymentStatus = (
],
canceled: [t("orders.payment.status.canceled"), "red"],
requires_action: [t("orders.payment.status.requiresAction"), "orange"],
}[status] as [string, "red" | "orange" | "green"]
}[status] ||
// TODO: remove this when Order<>Payment are linked
"not_paid") as [string, "red" | "orange" | "green"]
return { label, color }
}

View File

@@ -156,6 +156,10 @@ export const RouteMap: RouteObject[] = [
path: "",
lazy: () => import("../../v2-routes/orders/order-list"),
},
{
path: ":id",
lazy: () => import("../../v2-routes/orders/order-detail"),
},
],
},
{

View File

@@ -99,6 +99,11 @@ const FulfillmentBadge = ({ order }: { order: Order }) => {
const PaymentBadge = ({ order }: { order: Order }) => {
const { t } = useTranslation()
/**
* TODO: revisit when Order<>Payment are linked
*/
return null
const { label, color } = getOrderPaymentStatus(t, order.payment_status)
return (

View File

@@ -9,6 +9,7 @@ import {
FulfillmentProviderDTO,
InventoryNext,
InviteDTO,
OrderDTO,
PaymentProviderDTO,
PriceListDTO,
ProductCategoryDTO,
@@ -99,6 +100,10 @@ export type InviteRes = { invite: InviteDTO }
export type InviteListRes = { invites: InviteDTO[] } & ListRes
export type InviteDeleteRes = DeleteRes
// Orders
export type OrderRes = { order: OrderDTO }
export type OrderListRes = { orders: OrderDTO[] } & ListRes
// Products
export type ExtendedProductDTO = ProductDTO & {
variants: ProductVariantDTO[] | null

View File

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

View File

@@ -0,0 +1,25 @@
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { OrderNoteForm } from "./order-note-form"
import { OrderTimeline } from "./order-timeline"
import { OrderDTO } from "@medusajs/types"
type OrderActivityProps = {
order: OrderDTO
}
export const OrderActivitySection = ({ order }: OrderActivityProps) => {
const { t } = useTranslation()
return (
<Container className="flex flex-col gap-y-8 px-6 py-4">
<div className="flex flex-col gap-y-4">
<div className="flex items-center justify-between">
<Heading level="h2">{t("orders.activity.header")}</Heading>
</div>
<OrderNoteForm order={order} />
</div>
<OrderTimeline order={order} />
</Container>
)
}

View File

@@ -0,0 +1,111 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { ArrowUpCircleSolid } from "@medusajs/icons"
import { IconButton } from "@medusajs/ui"
import { useRef } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { OrderDTO } from "@medusajs/types"
type OrderNoteFormProps = {
order: OrderDTO
}
const OrderNoteSchema = z.object({
value: z.string().min(1),
})
export const OrderNoteForm = ({ order }: OrderNoteFormProps) => {
const { t } = useTranslation()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const form = useForm<z.infer<typeof OrderNoteSchema>>({
defaultValues: {
value: "",
},
resolver: zodResolver(OrderNoteSchema),
})
const { mutateAsync, isLoading } = {}
const handleSubmit = form.handleSubmit(async (values) => {
mutateAsync(
{
resource_id: order.id,
resource_type: "order",
value: values.value,
},
{
onSuccess: () => {
form.reset()
handleResetSize()
},
}
)
})
const handleResize = () => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = textarea.scrollHeight + "px"
}
}
const handleResetSize = () => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
}
}
return (
<div>
<Form {...form}>
<form onSubmit={handleSubmit}>
<div className="bg-ui-bg-field shadow-borders-base flex flex-col gap-y-2 rounded-md px-2 py-1.5">
<Form.Field
control={form.control}
name="value"
render={({ field }) => {
return (
<Form.Item>
<Form.Label hidden>
{t("orders.activity.comment.label")}
</Form.Label>
<Form.Control>
<textarea
{...field}
ref={textareaRef}
onInput={handleResize}
className="txt-small text-ui-fg-base placeholder:text-ui-fg-muted resize-none overflow-hidden bg-transparent outline-none"
placeholder={t("orders.activity.comment.placeholder")}
rows={1}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<div className="flex items-center justify-end">
<IconButton
type="submit"
isLoading={isLoading}
variant="transparent"
size="small"
className="text-ui-fg-muted hover:text-ui-fg-subtle active:text-ui-fg-subtle"
>
<span className="sr-only">
{t("orders.activity.comment.addButtonText")}
</span>
<ArrowUpCircleSolid />
</IconButton>
</div>
</div>
</form>
</Form>
</div>
)
}

View File

@@ -0,0 +1,404 @@
import { Fulfillment, Note, Order } from "@medusajs/medusa"
import { IconButton, Text, Tooltip, clx, usePrompt } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { PropsWithChildren, ReactNode, useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { XMarkMini } from "@medusajs/icons"
import { useTranslation } from "react-i18next"
import { Skeleton } from "../../../../../components/common/skeleton"
import { useDate } from "../../../../../hooks/use-date"
import { getStylizedAmount } from "../../../../../lib/money-amount-helpers"
import { OrderDTO } from "@medusajs/types"
type OrderTimelineProps = {
order: OrderDTO
}
/**
* Arbitrary high limit to ensure all notes are fetched
*/
const NOTE_LIMIT = 9999
export const OrderTimeline = ({ order }: OrderTimelineProps) => {
const items = useActivityItems(order)
if (items.length <= 3) {
return (
<div className="flex flex-col gap-y-0.5">
{items.map((item, index) => {
return (
<OrderActivityItem
key={index}
title={item.title}
timestamp={item.timestamp}
isFirst={index === items.length - 1}
>
{item.children}
</OrderActivityItem>
)
})}
</div>
)
}
const lastItems = items.slice(0, 2)
const collapsibleItems = items.slice(2, items.length - 1)
const firstItem = items[items.length - 1]
return (
<div className="flex flex-col gap-y-0.5">
{lastItems.map((item, index) => {
return (
<OrderActivityItem
key={index}
title={item.title}
timestamp={item.timestamp}
>
{item.children}
</OrderActivityItem>
)
})}
<OrderActivityCollapsible activities={collapsibleItems} />
<OrderActivityItem
title={firstItem.title}
timestamp={firstItem.timestamp}
isFirst
>
{firstItem.children}
</OrderActivityItem>
</div>
)
}
type Activity = {
title: string
timestamp: string | Date
children?: ReactNode
}
const useActivityItems = (order: Order) => {
const { t } = useTranslation()
const notes = []
const isLoading = false
// const { notes, isLoading, isError, error } = useNotes(
// {
// resource_id: order.id,
// limit: NOTE_LIMIT,
// offset: 0,
// },
// {
// keepPreviousData: true,
// }
// )
//
// if (isError) {
// throw error
// }
return useMemo(() => {
if (isLoading) {
return []
}
const items: Activity[] = []
// for (const payment of order.payments) {
// items.push({
// title: t("orders.activity.events.payment.awaiting"),
// timestamp: payment.created_at,
// children: (
// <Text size="small" className="text-ui-fg-subtle">
// {getStylizedAmount(payment.amount, payment.currency_code)}
// </Text>
// ),
// })
//
// if (payment.canceled_at) {
// items.push({
// title: t("orders.activity.events.payment.canceled"),
// timestamp: payment.canceled_at,
// children: (
// <Text size="small" className="text-ui-fg-subtle">
// {getStylizedAmount(payment.amount, payment.currency_code)}
// </Text>
// ),
// })
// }
//
// if (payment.captured_at) {
// items.push({
// title: t("orders.activity.events.payment.captured"),
// timestamp: payment.captured_at,
// children: (
// <Text size="small" className="text-ui-fg-subtle">
// {getStylizedAmount(payment.amount, payment.currency_code)}
// </Text>
// ),
// })
// }
// }
// for (const fulfillment of order.fulfillments) {
// items.push({
// title: t("orders.activity.events.fulfillment.created"),
// timestamp: fulfillment.created_at,
// children: <FulfillmentCreatedBody fulfillment={fulfillment} />,
// })
//
// if (fulfillment.shipped_at) {
// items.push({
// title: t("orders.activity.events.fulfillment.shipped"),
// timestamp: fulfillment.shipped_at,
// })
// }
// }
// for (const ret of order.returns) {
// items.push({
// title: t("orders.activity.events.return.created"),
// timestamp: ret.created_at,
// })
// }
// for (const note of notes || []) {
// items.push({
// title: t("orders.activity.events.note.comment"),
// timestamp: note.created_at,
// children: <NoteBody note={note} />,
// })
// }
if (order.canceled_at) {
items.push({
title: t("orders.activity.events.canceled.title"),
timestamp: order.canceled_at,
})
}
const sortedActivities = items.sort((a, b) => {
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
})
const createdAt = {
title: t("orders.activity.events.placed.title"),
timestamp: order.created_at,
children: (
<Text size="small" className="text-ui-fg-subtle">
{t("orders.activity.events.placed.fromSalesChannel", {
salesChannel: order.sales_channel.name,
})}
</Text>
),
}
return [...sortedActivities, createdAt]
}, [order, notes, isLoading, t])
}
type OrderActivityItemProps = PropsWithChildren<{
title: string
timestamp: string | Date
isFirst?: boolean
}>
const OrderActivityItem = ({
title,
timestamp,
isFirst = false,
children,
}: OrderActivityItemProps) => {
const { getFullDate, getRelativeDate } = useDate()
return (
<div className="grid grid-cols-[20px_1fr] items-start gap-2">
<div className="flex size-full flex-col items-center gap-y-0.5">
<div className="flex size-5 items-center justify-center">
<div className="bg-ui-bg-base shadow-borders-base flex size-2.5 items-center justify-center rounded-full">
<div className="bg-ui-tag-neutral-icon size-1.5 rounded-full" />
</div>
</div>
{!isFirst && <div className="bg-ui-border-base w-px flex-1" />}
</div>
<div
className={clx({
"pb-4": !isFirst,
})}
>
<div className="flex items-center justify-between">
<Text size="small" leading="compact" weight="plus">
{title}
</Text>
<Tooltip
content={getFullDate({ date: timestamp, includeTime: true })}
>
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{getRelativeDate(timestamp)}
</Text>
</Tooltip>
</div>
<div>{children}</div>
</div>
</div>
)
}
const OrderActivityCollapsible = ({
activities,
}: {
activities: Activity[]
}) => {
const [open, setOpen] = useState(false)
const { t } = useTranslation()
if (!activities.length) {
return null
}
return (
<Collapsible.Root open={open} onOpenChange={setOpen}>
{!open && (
<div className="grid grid-cols-[20px_1fr] items-start gap-2">
<div className="flex size-full flex-col items-center">
<div className="border-ui-border-strong w-px flex-1 bg-[linear-gradient(var(--border-strong)_33%,rgba(255,255,255,0)_0%)] bg-[length:1px_3px] bg-right bg-repeat-y" />
</div>
<div className="pb-4">
<Collapsible.Trigger className="text-left">
<Text
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-muted"
>
{t("orders.activity.showMoreActivities", {
count: activities.length,
})}
</Text>
</Collapsible.Trigger>
</div>
</div>
)}
<Collapsible.Content>
<div className="flex flex-col gap-y-0.5">
{activities.map((item, index) => {
return (
<OrderActivityItem
key={index}
title={item.title}
timestamp={item.timestamp}
>
{item.children}
</OrderActivityItem>
)
})}
</div>
</Collapsible.Content>
</Collapsible.Root>
)
}
const NoteBody = ({ note }: { note: Note }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { first_name, last_name, email } = note.author || {}
const name = [first_name, last_name].filter(Boolean).join(" ")
const byLine = t("orders.activity.events.note.byLine", {
author: name || email,
})
const { mutateAsync } = useAdminDeleteNote(note.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: "This action cannot be undone",
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<div className="flex flex-col gap-y-2 pt-2">
<div className="bg-ui-bg-component shadow-borders-base group grid grid-cols-[1fr_20px] items-start gap-x-2 text-pretty rounded-r-2xl rounded-bl-md rounded-tl-xl px-3 py-1.5">
<div className="flex h-full min-h-7 items-center">
<Text size="xsmall" className="text-ui-fg-subtle">
{note.value}
</Text>
</div>
<IconButton
size="small"
variant="transparent"
className="transition-fg invisible opacity-0 group-hover:visible group-hover:opacity-100"
type="button"
onClick={handleDelete}
>
<span className="sr-only">
{t("orders.activity.comment.deleteButtonText")}
</span>
<XMarkMini className="text-ui-fg-muted" />
</IconButton>
</div>
<Link
to={`/settings/users/${note.author_id}`}
className="text-ui-fg-subtle hover:text-ui-fg-base transition-fg w-fit"
>
<Text size="small">{byLine}</Text>
</Link>
</div>
)
}
const FulfillmentCreatedBody = ({
fulfillment,
}: {
fulfillment: Fulfillment
}) => {
const { t } = useTranslation()
const { stock_location, isLoading, isError, error } = useAdminStockLocation(
fulfillment.location_id!,
{
enabled: !!fulfillment.location_id,
}
)
const numberOfItems = fulfillment.items.reduce((acc, item) => {
return acc + item.quantity
}, 0)
const triggerText = stock_location
? t("orders.activity.events.fulfillment.itemsFulfilledFrom", {
count: numberOfItems,
location: stock_location.name,
})
: t("orders.activity.events.fulfillment.itemsFulfilled", {
count: numberOfItems,
})
if (isError) {
throw error
}
return (
<div>
{isLoading ? (
<Skeleton className="h-7 w-full" />
) : (
<Text size="small" className="text-ui-fg-subtle">
{triggerText}
</Text>
)}
</div>
)
}

View File

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

View File

@@ -0,0 +1,69 @@
import { Order } from "@medusajs/medusa"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ArrowPath, CurrencyDollar, Envelope, FlyingBox } from "@medusajs/icons"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { CustomerInfo } from "../../../../../components/common/customer-info"
type OrderCustomerSectionProps = {
order: Order
}
export const OrderCustomerSection = ({ order }: OrderCustomerSectionProps) => {
return (
<Container className="divide-y p-0">
<Header />
<CustomerInfo.ID data={order} />
<CustomerInfo.Contact data={order} />
<CustomerInfo.Company data={order} />
<CustomerInfo.Addresses data={order} />
</Container>
)
}
const Header = () => {
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("transferOwnership.label"),
to: `transfer-ownership`,
icon: <ArrowPath />,
},
],
},
{
actions: [
{
label: t("addresses.shippingAddress.editLabel"),
to: "shipping-address",
icon: <FlyingBox />,
},
{
label: t("addresses.billingAddress.editLabel"),
to: "billing-address",
icon: <CurrencyDollar />,
},
],
},
{
actions: [
{
label: t("email.editLabel"),
to: `email`,
icon: <Envelope />,
},
],
},
]}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,340 @@
import { XCircle } from "@medusajs/icons"
import {
LineItem,
Fulfillment as MedusaFulfillment,
Order,
} from "@medusajs/medusa"
import {
Container,
Copy,
Heading,
StatusBadge,
Text,
toast,
Tooltip,
usePrompt,
} from "@medusajs/ui"
import { format } from "date-fns"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { FulfillmentDTO, OrderDTO, OrderItemDTO } from "@medusajs/types"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { Skeleton } from "../../../../../components/common/skeleton"
import { Thumbnail } from "../../../../../components/common/thumbnail"
import { formatProvider } from "../../../../../lib/format-provider"
import { getLocaleAmount } from "../../../../../lib/money-amount-helpers"
import { useStockLocation } from "../../../../../hooks/api/stock-locations"
type OrderFulfillmentSectionProps = {
order: OrderDTO
}
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} fulfillment={f} />
))}
</div>
)
}
const UnfulfilledItem = ({
item,
currencyCode,
}: {
item: OrderItemDTO
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,
index,
}: {
fulfillment: FulfillmentDTO
index: number
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const showLocation = !!fulfillment.location_id
const { stock_location, isError, error } = useStockLocation(
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 } = {} // useCancelFulfillment(order.id)
const handleCancel = async () => {
if (fulfillment.shipped_at) {
toast.warning(t("general.warning"), {
description: t("orders.fulfillment.toast.fulfillmentShipped"),
dismissLabel: t("actions.close"),
})
return
}
const res = await prompt({
title: t("general.areYouSure"),
description: t("orders.fulfillment.cancelWarning"),
confirmText: t("actions.continue"),
cancelText: t("actions.cancel"),
})
if (res) {
try {
await mutateAsync(fulfillment.id)
toast.success(t("general.success"), {
description: t("orders.fulfillment.toast.canceled"),
dismissLabel: t("actions.close"),
})
} catch (e) {
toast.error(t("general.error"), {
description: e.message,
dismissLabel: t("actions.close"),
})
}
}
}
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-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.provider")}
</Text>
<Text size="small" leading="compact">
{formatProvider(fulfillment.provider_id)}
</Text>
</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,118 @@
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 { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import {
getOrderFulfillmentStatus,
getOrderPaymentStatus,
} from "../../../../../lib/order-helpers"
type OrderGeneralSectionProps = {
order: Order
}
export const OrderGeneralSection = ({ order }: OrderGeneralSectionProps) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = { mutateAsync: () => {} } // cancel order
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()
/**
* TODO: revisit when Order<>Fulfillment are linked
*/
return null
const { label, color } = getOrderFulfillmentStatus(
t,
order.fulfillment_status
)
return (
<StatusBadge color={color} className="text-nowrap">
{label}
</StatusBadge>
)
}
const PaymentBadge = ({ order }: { order: Order }) => {
const { t } = useTranslation()
/**
* TODO: revisit when Order<>Payment are linked
*/
return null
const { label, color } = getOrderPaymentStatus(t, order.payment_status)
return (
<StatusBadge color={color} className="text-nowrap">
{label}
</StatusBadge>
)
}

View File

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

View File

@@ -0,0 +1,284 @@
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()
const hasCapturedPayment = order.payments.some((p) => !!p.captured_at)
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: `/orders/${order.id}/refund`,
disabled: !hasCapturedPayment,
},
],
},
]}
/>
</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: `/orders/${payment.order_id}/refund?paymentId=${payment.id}`,
disabled: !payment.captured_at,
},
],
},
]}
/>
</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,246 @@
import { Buildings, PencilSquare, ArrowUturnLeft } from "@medusajs/icons"
import { OrderDTO, OrderLineItemDTO, ReservationItemDTO } from "@medusajs/types"
import { Container, Copy, Heading, StatusBadge, Text } from "@medusajs/ui"
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: OrderDTO
}
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: OrderDTO }) => {
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: `/orders/${order.id}/edit`,
// icon: <PencilSquare />,
// },
// {
// label: t("orders.summary.allocateItems"),
// to: "#", // TODO: Open modal to allocate items
// icon: <Buildings />,
// },
// {
// label: t("orders.summary.requestReturn"),
// to: `/orders/${order.id}/returns`,
// icon: <ArrowUturnLeft />,
// },
],
},
]}
/>
</div>
)
}
const Item = ({
item,
currencyCode,
reservation,
}: {
item: OrderLineItemDTO
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: OrderDTO }) => {
// 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={null /* TODO: fetch reservation for this item */}
/>
)
})}
</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: OrderDTO }) => {
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")}
// TODO: ORDER<>DISCOUNTS link
// 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")}
// TODO: ORDER<>SHIPPING link
// secondaryValue={order.shipping_methods
// .map((sm) => sm.shipping_option.name)
// .join(", ")}
value={getLocaleAmount(order.shipping_total, order.currency_code)}
/>
<Cost
label={t("fields.tax")}
// TODO: TAX_RATE is missing on order
secondaryValue={`${order.tax_rate || 0}%`}
value={
order.tax_total
? getLocaleAmount(order.tax_total, order.currency_code)
: "-"
}
/>
</div>
)
}
const Total = ({ order }: { order: OrderDTO }) => {
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,30 @@
const DEFAULT_PROPERTIES = [
"id",
"status",
"created_at",
"email",
// "fulfillment_status", // -> TODO replacement for this
// "payment_status", // -> TODO replacement for this
"display_id",
"currency_code",
// --- TOTALS ---
"total",
"subtotal",
"discounts_total",
"shipping_total",
"tax_total",
]
const DEFAULT_RELATIONS = [
"*customer",
"*items", // -> we get LineItem here with added `quantity` and `detail` which is actually an OrderItem (which is a parent object to LineItem in the DB)
"*items.variant.options",
"*shipping_address",
"*billing_address",
"*sales_channel",
"*promotion",
]
export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join(
","
)},${DEFAULT_RELATIONS.join(",")}`

View File

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

View File

@@ -0,0 +1,26 @@
import { AdminOrdersRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { adminOrderKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { queryClient } from "../../../lib/medusa"
import { DEFAULT_FIELDS } from "./constants"
import { client } from "../../../lib/client"
const orderDetailQuery = (id: string) => ({
queryKey: adminOrderKeys.detail(id),
queryFn: async () =>
client.orders.retrieve(id, {
fields: DEFAULT_FIELDS,
}),
})
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,57 @@
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { OrderActivitySection } from "./components/order-activity-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 { DEFAULT_FIELDS } from "./constants"
import { orderLoader } from "./loader"
import { useOrder } from "../../../hooks/api/orders"
export const OrderDetail = () => {
const initialData = useLoaderData() as Awaited<ReturnType<typeof orderLoader>>
const { id } = useParams()
const { order, isLoading, isError, error } = useOrder(
id!,
{
fields: DEFAULT_FIELDS,
},
{
initialData,
}
)
if (isLoading || !order) {
return <div>Loading...</div>
}
if (isError) {
throw error
}
return (
<div className="flex flex-col gap-x-4 xl:flex-row xl:items-start">
<div className="flex w-full 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 xl:hidden">
<OrderCustomerSection order={order} />
<OrderActivitySection order={order} />
</div>
<JsonViewSection data={order} />
</div>
<div className="hidden w-full max-w-[400px] flex-col gap-y-2 xl:flex">
<OrderCustomerSection order={order} />
<OrderActivitySection order={order} />
</div>
<Outlet />
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./order-list-table"

View File

@@ -0,0 +1,67 @@
import { keepPreviousData } from "@tanstack/react-query"
import { Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { DataTable } from "../../../../../components/table/data-table/data-table"
import { useOrderTableColumns } from "../../../../../hooks/table/columns/use-order-table-columns"
import { useOrderTableFilters } from "../../../../../hooks/table/filters/use-order-table-filters"
import { useOrderTableQuery } from "../../../../../hooks/table/query/use-order-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useOrders } from "../../../../../hooks/api/orders"
import { DEFAULT_FIELDS } from "../../const"
const PAGE_SIZE = 20
export const OrderListTable = () => {
const { t } = useTranslation()
const { searchParams, raw } = useOrderTableQuery({
pageSize: PAGE_SIZE,
})
const { orders, count, isError, error, isLoading } = useOrders(
{
fields: DEFAULT_FIELDS,
...searchParams,
},
{
placeholderData: keepPreviousData,
}
)
const filters = useOrderTableFilters()
const columns = useOrderTableColumns({})
const { table } = useDataTable({
data: orders ?? [],
columns,
enablePagination: true,
count,
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("orders.domain")}</Heading>
</div>
<DataTable
columns={columns}
table={table}
pagination
navigateTo={(row) => `/orders/${row.original.id}`}
filters={filters}
count={count}
search
isLoading={isLoading}
pageSize={PAGE_SIZE}
orderBy={["display_id", "created_at", "updated_at"]}
queryObject={raw}
/>
</Container>
)
}

View File

@@ -0,0 +1,17 @@
const DEFAULT_PROPERTIES = [
"id",
"status",
"created_at",
"email",
"display_id",
// "fulfillment_status", // -> TODO replacement for this
// "payment_status", // -> TODO replacement for this
"total",
"currency_code",
]
const DEFAULT_RELATIONS = ["*customer", "*sales_channel"]
export const DEFAULT_FIELDS = `${DEFAULT_PROPERTIES.join(
","
)},${DEFAULT_RELATIONS.join(",")}`

View File

@@ -1,5 +1,9 @@
// TODO: This is just a placeholder to render the actual "home" page
// after logging in. Replace this with the actual order components when ready
import { OrderListTable } from "./components/order-list-table"
export const OrderList = () => {
return <div className="flex w-full flex-col gap-y-2"></div>
return (
<div className="flex w-full flex-col gap-y-2">
<OrderListTable />
</div>
)
}