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:
Kasper Fabricius Kristensen
2024-03-15 11:29:59 +01:00
committed by GitHub
parent 68d869607f
commit c3f26a6826
70 changed files with 2884 additions and 573 deletions

View File

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

View File

@@ -0,0 +1 @@
export * from "./address-form"

View File

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

View File

@@ -0,0 +1 @@
export * from "./email-form"

View File

@@ -0,0 +1 @@
export * from "./transfer-ownership-form"

View File

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