feat(dashboard,medusa): Draft order detail (#6703)
**What** - Adds draft order details page - Adds Shipping and Billing address forms to both draft order and order pages - Adds Email form to both draft order and order pages - Adds transfer ownership form to draft order, order and customer pages - Update Combobox component allowing it to work with async data (`useInfiniteQuery`) **@medusajs/medusa** - Include country as a default relation of draft order addresses
This commit is contained in:
committed by
GitHub
parent
68d869607f
commit
c3f26a6826
@@ -0,0 +1,213 @@
|
||||
import { Country } from "@medusajs/medusa"
|
||||
import { Heading, Input, Select, clx } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Control } from "react-hook-form"
|
||||
import { AddressSchema } from "../../../lib/schemas"
|
||||
import { CountrySelect } from "../../common/country-select"
|
||||
import { Form } from "../../common/form"
|
||||
|
||||
type AddressFieldValues = z.infer<typeof AddressSchema>
|
||||
|
||||
type AddressFormProps = {
|
||||
control: Control<AddressFieldValues>
|
||||
countries?: Country[]
|
||||
layout: "grid" | "stack"
|
||||
}
|
||||
|
||||
export const AddressForm = ({
|
||||
control,
|
||||
countries,
|
||||
layout,
|
||||
}: AddressFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const style = clx("gap-4", {
|
||||
"flex flex-col": layout === "stack",
|
||||
"grid grid-cols-2": layout === "grid",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("addresses.contactHeading")}</Heading>
|
||||
<fieldset className={style}>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="first_name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.firstName")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="last_name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.lastName")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="company"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.company")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="phone"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.phone")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("addresses.locationHeading")}</Heading>
|
||||
<fieldset className={style}>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="address_1"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.address")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="address_2"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.address2")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="city"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.city")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="postal_code"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.postalCode")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="province"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>{t("fields.province")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="country_code"
|
||||
render={({ field: { ref, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.country")}</Form.Label>
|
||||
<Form.Control>
|
||||
{countries ? (
|
||||
<Select {...field} onValueChange={onChange}>
|
||||
<Select.Trigger ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{countries.map((country) => (
|
||||
<Select.Item
|
||||
key={country.iso_2}
|
||||
value={country.iso_2}
|
||||
>
|
||||
{country.display_name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
) : (
|
||||
<CountrySelect {...field} ref={ref} onChange={onChange} /> // When no countries are provided, use the country select component that has a built-in list of all countries
|
||||
)}
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./address-form"
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Input, clx } from "@medusajs/ui"
|
||||
import { Control } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
import { EmailSchema } from "../../../lib/schemas"
|
||||
import { Form } from "../../common/form"
|
||||
|
||||
type EmailFieldValues = z.infer<typeof EmailSchema>
|
||||
|
||||
type EmailFormProps = {
|
||||
control: Control<EmailFieldValues>
|
||||
layout?: "grid" | "stack"
|
||||
}
|
||||
|
||||
export const EmailForm = ({ control, layout = "stack" }: EmailFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clx("gap-4", {
|
||||
"flex flex-col": layout === "stack",
|
||||
"grid grid-cols-2": layout === "grid",
|
||||
})}
|
||||
>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="email"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.email")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./email-form"
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./transfer-ownership-form"
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Customer, DraftOrder, Order } from "@medusajs/medusa"
|
||||
import { Select, Text, clx } from "@medusajs/ui"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { format } from "date-fns"
|
||||
import { debounce } from "lodash"
|
||||
import { useAdminCustomer } from "medusa-react"
|
||||
import { PropsWithChildren, useCallback, useEffect, useState } from "react"
|
||||
import { Control, useWatch } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { z } from "zod"
|
||||
import { medusa } from "../../../lib/medusa"
|
||||
import { getStylizedAmount } from "../../../lib/money-amount-helpers"
|
||||
import {
|
||||
getOrderFulfillmentStatus,
|
||||
getOrderPaymentStatus,
|
||||
} from "../../../lib/order-helpers"
|
||||
import { TransferOwnershipSchema } from "../../../lib/schemas"
|
||||
import { Combobox } from "../../common/combobox"
|
||||
import { Form } from "../../common/form"
|
||||
import { Skeleton } from "../../common/skeleton"
|
||||
|
||||
type TransferOwnerShipFieldValues = z.infer<typeof TransferOwnershipSchema>
|
||||
|
||||
type TransferOwnerShipFormProps = {
|
||||
/**
|
||||
* The Order or DraftOrder to transfer ownership of.
|
||||
*/
|
||||
order: Order | DraftOrder
|
||||
/**
|
||||
* React Hook Form control object.
|
||||
*/
|
||||
control: Control<TransferOwnerShipFieldValues>
|
||||
}
|
||||
|
||||
const isOrder = (order: Order | DraftOrder): order is Order => {
|
||||
return "customer" in order
|
||||
}
|
||||
|
||||
export const TransferOwnerShipForm = ({
|
||||
order,
|
||||
control,
|
||||
}: TransferOwnerShipFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
|
||||
const isOrderType = isOrder(order)
|
||||
const currentOwnerId = useWatch({
|
||||
control,
|
||||
name: "current_owner_id",
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedUpdate = useCallback(
|
||||
debounce((query) => setDebouncedQuery(query), 300),
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
debouncedUpdate(query)
|
||||
|
||||
return () => debouncedUpdate.cancel()
|
||||
}, [query, debouncedUpdate])
|
||||
|
||||
const {
|
||||
customer: owner,
|
||||
isLoading: isLoadingOwner,
|
||||
isError: isOwnerError,
|
||||
error: ownerError,
|
||||
} = useAdminCustomer(currentOwnerId)
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
["customers", debouncedQuery],
|
||||
async ({ pageParam = 0 }) => {
|
||||
const res = await medusa.admin.customers.list({
|
||||
q: debouncedQuery,
|
||||
limit: 10,
|
||||
offset: pageParam,
|
||||
has_account: true, // Only show customers with confirmed accounts
|
||||
})
|
||||
return res
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreCustomersExist =
|
||||
lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreCustomersExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const createLabel = (customer?: Customer) => {
|
||||
if (!customer) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const { first_name, last_name, email } = customer
|
||||
|
||||
const name = [first_name, last_name].filter(Boolean).join(" ")
|
||||
|
||||
if (name) {
|
||||
return `${name} (${email})`
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
const ownerReady = !isLoadingOwner && owner
|
||||
|
||||
const options =
|
||||
data?.pages
|
||||
.map((p) =>
|
||||
p.customers.map((c) => ({
|
||||
label: createLabel(c),
|
||||
value: c.id,
|
||||
}))
|
||||
)
|
||||
.flat() || []
|
||||
|
||||
if (isOwnerError) {
|
||||
throw ownerError
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{isOrderType
|
||||
? t("transferOwnership.details.order")
|
||||
: t("transferOwnership.details.draft")}
|
||||
</Text>
|
||||
{isOrderType ? (
|
||||
<OrderDetailsTable order={order} />
|
||||
) : (
|
||||
<DraftOrderDetailsTable draft={order} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("transferOwnership.currentOwner.label")}
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("transferOwnership.currentOwner.hint")}
|
||||
</Text>
|
||||
</div>
|
||||
{ownerReady ? (
|
||||
<Select defaultValue={owner.id} disabled>
|
||||
<Select.Trigger>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={owner.id}>{createLabel(owner)}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select>
|
||||
) : (
|
||||
<Skeleton className="h-8 w-full rounded-md" />
|
||||
)}
|
||||
</div>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name="new_owner_id"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col">
|
||||
<Form.Label>{t("transferOwnership.newOwner.label")}</Form.Label>
|
||||
<Form.Hint>{t("transferOwnership.newOwner.hint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
searchValue={query}
|
||||
onSearchValueChange={setQuery}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
options={options}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OrderDetailsTable = ({ order }: { order: Order }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { label: fulfillmentLabel } = getOrderFulfillmentStatus(
|
||||
t,
|
||||
order.fulfillment_status
|
||||
)
|
||||
|
||||
const { label: paymentLabel } = getOrderPaymentStatus(t, order.payment_status)
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Row label={t("fields.order")} value={`#${order.display_id}`} />
|
||||
<DateRow date={order.created_at} />
|
||||
<Row label={t("fields.fulfillment")} value={fulfillmentLabel} />
|
||||
<Row label={t("fields.payment")} value={paymentLabel} />
|
||||
<TotalRow total={order.total || 0} currencyCode={order.currency_code} />
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const DraftOrderDetailsTable = ({ draft }: { draft: DraftOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<Row label={t("fields.draft")} value={`#${draft.display_id}`} />
|
||||
<DateRow date={draft.created_at} />
|
||||
<Row
|
||||
label={t("fields.status")}
|
||||
value={t(`draftOrders.status.${draft.status}`)}
|
||||
/>
|
||||
<TotalRow
|
||||
total={draft.cart.total || 0}
|
||||
currencyCode={draft.cart.region.currency_code}
|
||||
/>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
const DateRow = ({ date }: { date: string | Date }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const formattedDate = format(new Date(date), "dd MMM yyyy")
|
||||
|
||||
return <Row label={t("fields.date")} value={formattedDate} />
|
||||
}
|
||||
|
||||
const TotalRow = ({
|
||||
total,
|
||||
currencyCode,
|
||||
}: {
|
||||
total: number
|
||||
currencyCode: string
|
||||
}) => {
|
||||
return <Row label="Total" value={getStylizedAmount(total, currencyCode)} />
|
||||
}
|
||||
|
||||
const Row = ({ label, value }: { label: string; value: string }) => {
|
||||
return (
|
||||
<div className="txt-compact-small grid grid-cols-2 divide-x">
|
||||
<div className="text-ui-fg-muted px-2 py-1.5">{label}</div>
|
||||
<div className="text-ui-fg-subtle px-2 py-1.5">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Table = ({ children }: PropsWithChildren) => {
|
||||
return <div className={clx("divide-y rounded-lg border")}>{children}</div>
|
||||
}
|
||||
Reference in New Issue
Block a user