feat(dashboard): Draft orders create (#6680)
**What** - Adds Create draft order form - Updates draft order details page to also display "custom" items. **Note** - Currently, the form is missing a way to input a discount code. Need to rethink this a bit, as the we can't implement the design in Figma. - The current design is missing a way to select from a customers existing shipping addresses, we should add that to keep the features we have today. - This PR uses `useInfiniteQuery` which does not work on our staging (due to duplicate dependencies as a result of building straight from the monorepo), so you will need to test locally.
This commit is contained in:
+130
@@ -0,0 +1,130 @@
|
||||
import i18n from "i18next"
|
||||
import { z } from "zod"
|
||||
import { castNumber } from "../../../../../lib/cast-number"
|
||||
|
||||
export const AddressPayload = z.object({
|
||||
first_name: z.string().min(1),
|
||||
last_name: z.string().min(1),
|
||||
address_1: z.string().min(1),
|
||||
address_2: z.string().optional(),
|
||||
city: z.string().min(1),
|
||||
province: z.string().optional(),
|
||||
postal_code: z.string().min(1),
|
||||
country_code: z.string().min(1),
|
||||
phone: z.string().optional(),
|
||||
company: z.string().optional(),
|
||||
})
|
||||
|
||||
export const ExistingItemSchema = z
|
||||
.object({
|
||||
product_title: z.string().optional(),
|
||||
thumbnail: z.string().optional(),
|
||||
variant_title: z.string().optional(),
|
||||
variant_id: z.string().min(1),
|
||||
sku: z.string().optional(),
|
||||
quantity: z.number().min(1),
|
||||
unit_price: z.number().min(0),
|
||||
custom_unit_price: z.union([z.number(), z.string()]).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.custom_unit_price && isNaN(castNumber(data.custom_unit_price))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid custom unit price",
|
||||
path: ["custom_unit_price"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const CustomItemSchema = z
|
||||
.object({
|
||||
title: z.string().min(1),
|
||||
quantity: z.number().min(1),
|
||||
unit_price: z.union([z.number(), z.string()]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (
|
||||
typeof data.unit_price === "string" &&
|
||||
isNaN(castNumber(data.unit_price))
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid unit price",
|
||||
path: ["unit_price"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const ShippingMethodSchema = z
|
||||
.object({
|
||||
option_id: z.string().min(1),
|
||||
option_title: z.string(),
|
||||
amount: z.union([z.number(), z.string()]).optional(),
|
||||
custom_amount: z.union([z.number(), z.string()]).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.custom_amount && isNaN(castNumber(data.custom_amount))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid custom amount",
|
||||
path: ["custom_amount"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const CreateDraftOrderSchema = z
|
||||
.object({
|
||||
email: z.string().optional(),
|
||||
region_id: z.string().min(1),
|
||||
customer_id: z.string().optional(),
|
||||
shipping_address: AddressPayload,
|
||||
billing_address: AddressPayload.nullable(),
|
||||
existing_items: z.array(ExistingItemSchema).optional(),
|
||||
custom_items: z.array(CustomItemSchema).optional(),
|
||||
shipping_method: ShippingMethodSchema,
|
||||
notification_order: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.existing_items?.length && !data.custom_items?.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: i18n.t("draftOrders.validation.requiredItems"),
|
||||
path: ["custom_items"],
|
||||
})
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: i18n.t("draftOrders.validation.requiredItems"),
|
||||
path: ["existing_items"],
|
||||
})
|
||||
}
|
||||
|
||||
if (!data.email && !data.customer_id) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: i18n.t("draftOrders.validation.requiredEmailOrCustomer"),
|
||||
path: ["customer_id"],
|
||||
})
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: i18n.t("draftOrders.validation.requiredEmailOrCustomer"),
|
||||
path: ["email"],
|
||||
})
|
||||
} else if (!data.customer_id && data.email) {
|
||||
const parsedEmail = z.string().email().safeParse(data.email)
|
||||
|
||||
if (!parsedEmail.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: i18n.t("draftOrders.validation.invalidEmail"),
|
||||
path: ["email"],
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export enum View {
|
||||
EXISTING_ITEMS = "existing_items",
|
||||
CUSTOM_ITEMS = "custom_items",
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "react"
|
||||
import { CreateDraftOrderContextValue } from "./types"
|
||||
|
||||
export const CreateDraftOrderContext =
|
||||
createContext<CreateDraftOrderContextValue | null>(null)
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
import { Region } from "@medusajs/medusa"
|
||||
import { Checkbox, Heading, Input, Label, Select, Text } from "@medusajs/ui"
|
||||
import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
import { Control } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { ConditionalTooltip } from "../../../../../../components/common/conditional-tooltip"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { CreateDraftOrderSchema } from "../constants"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderAddressDetails = () => {
|
||||
const { t } = useTranslation()
|
||||
const { form, region, sameAsShipping, setSameAsShipping } =
|
||||
useCreateDraftOrder()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("fields.address")}</Heading>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("addresses.shippingAddress.label")}
|
||||
</Text>
|
||||
<AddressFieldset
|
||||
field="shipping_address"
|
||||
region={region}
|
||||
control={form.control}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("addresses.billingAddress.label")}
|
||||
</Text>
|
||||
<Label className="flex cursor-pointer items-center gap-x-2">
|
||||
<Checkbox
|
||||
checked={sameAsShipping}
|
||||
onCheckedChange={(checked) => setSameAsShipping(checked === true)}
|
||||
/>
|
||||
{t("addresses.billingAddress.sameAsShipping")}
|
||||
</Label>
|
||||
</div>
|
||||
<Collapsible.Root open={!sameAsShipping}>
|
||||
<Collapsible.Content>
|
||||
<AddressFieldset
|
||||
field="billing_address"
|
||||
region={region}
|
||||
control={form.control}
|
||||
/>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AddressFieldset = ({
|
||||
field,
|
||||
control,
|
||||
region,
|
||||
}: {
|
||||
field: "shipping_address" | "billing_address"
|
||||
region: Region | null
|
||||
control: Control<z.infer<typeof CreateDraftOrderSchema>>
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.first_name`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.firstName")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.last_name`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.lastName")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.company`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.company")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.phone`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.phone")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.address_1`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.address")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.address_2`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.address2")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.city`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.city")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.postal_code`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.postalCode")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.province`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.province")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={control}
|
||||
name={`${field}.country_code`}
|
||||
render={({
|
||||
field: { onChange, ref, disabled, ...field },
|
||||
fieldState: { error },
|
||||
}) => {
|
||||
return (
|
||||
<ConditionalTooltip
|
||||
showTooltip={!region}
|
||||
content={t("draftOrders.create.chooseRegionTooltip")}
|
||||
>
|
||||
<Form.Item>
|
||||
<Form.Label className="text-ui-fg-subtle font-normal">
|
||||
{t("fields.country")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Select
|
||||
disabled={!region || disabled}
|
||||
onValueChange={onChange}
|
||||
{...field}
|
||||
>
|
||||
<Select.Trigger aria-invalid={!!error} ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{region?.countries.map((c) => (
|
||||
<Select.Item key={c.iso_2} value={c.iso_2}>
|
||||
{c.display_name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
</ConditionalTooltip>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { Customer } from "@medusajs/medusa"
|
||||
import { Checkbox, Heading, Input, Label } from "@medusajs/ui"
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import { debounce } from "lodash"
|
||||
import { useMedusa } from "medusa-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { json } from "react-router-dom"
|
||||
|
||||
import { Combobox } from "../../../../../../components/common/combobox"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderCustomerDetails = () => {
|
||||
const [useExistingCustomer, setUseExistingCustomer] = useState(true)
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { form, setCustomer } = useCreateDraftOrder()
|
||||
|
||||
// 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 { client } = useMedusa()
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
["customers", debouncedQuery],
|
||||
async ({ pageParam = 0 }) => {
|
||||
const res = await client.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 handleCustomerChange = (cusId: string | undefined) => {
|
||||
if (!cusId) {
|
||||
setCustomer(null)
|
||||
return
|
||||
}
|
||||
|
||||
const customer = data?.pages
|
||||
.flatMap((page) => page.customers)
|
||||
.find((c) => c.id === cusId)
|
||||
|
||||
if (!customer) {
|
||||
throw json({ message: "Customer not found" }, 400)
|
||||
}
|
||||
|
||||
form.setValue("email", customer.email, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
setCustomer(customer)
|
||||
}
|
||||
|
||||
const options =
|
||||
data?.pages.flatMap((page) =>
|
||||
page.customers.map((c) => ({ label: createLabel(c), value: c.id }))
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("fields.customer")}</Heading>
|
||||
<fieldset className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{useExistingCustomer ? (
|
||||
<Form.Field
|
||||
key="customer-input"
|
||||
control={form.control}
|
||||
name="customer_id"
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label className="text-ui-fg-subtle !font-normal">
|
||||
{t("fields.customer")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
onChange={(val) => {
|
||||
onChange(val)
|
||||
handleCustomerChange(val)
|
||||
}}
|
||||
searchValue={query}
|
||||
onSearchValueChange={setQuery}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
options={options}
|
||||
autoComplete="false"
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Form.Field
|
||||
key="email-input"
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label className="text-ui-fg-subtle !font-normal">
|
||||
{t("fields.email")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</fieldset>
|
||||
<Label className="flex w-fit items-center gap-x-2">
|
||||
<Checkbox
|
||||
checked={useExistingCustomer}
|
||||
onCheckedChange={(val) => setUseExistingCustomer(!!val)}
|
||||
/>
|
||||
<span>{t("draftOrders.create.useExistingCustomerLabel")}</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Heading, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Divider } from "../../../../../../components/common/divider"
|
||||
import { CreateDraftOrderAddressDetails } from "./create-draft-order-address-details"
|
||||
import { CreateDraftOrderCustomerDetails } from "./create-draft-order-customer-details"
|
||||
import { CreateDraftOrderItemsDetails } from "./create-draft-order-items-details"
|
||||
import { CreateDraftOrderRegionDetails } from "./create-draft-order-region-details"
|
||||
import { CreateDraftOrderShippingMethodDetails } from "./create-draft-order-shipping-method-details"
|
||||
|
||||
export const CreateDraftOrderDetails = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center p-16">
|
||||
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
|
||||
<div>
|
||||
<Heading>{t("draftOrders.create.createDraftOrder")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("draftOrders.create.createDraftOrderHint")}
|
||||
</Text>
|
||||
</div>
|
||||
<CreateDraftOrderRegionDetails />
|
||||
<Divider />
|
||||
<CreateDraftOrderCustomerDetails />
|
||||
<Divider />
|
||||
<CreateDraftOrderItemsDetails />
|
||||
<Divider />
|
||||
<CreateDraftOrderShippingMethodDetails />
|
||||
<Divider />
|
||||
<CreateDraftOrderAddressDetails />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
import { Trash } from "@medusajs/icons"
|
||||
import { Button, CurrencyInput, Input, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { ActionMenu } from "../../../../../../components/common/action-menu"
|
||||
import { ConditionalTooltip } from "../../../../../../components/common/conditional-tooltip"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { Thumbnail } from "../../../../../../components/common/thumbnail"
|
||||
import { getStylizedAmount } from "../../../../../../lib/money-amount-helpers"
|
||||
import { View } from "../constants"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderItemsDetails = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { region, variants, custom, form, onOpenDrawer } = useCreateDraftOrder()
|
||||
const { currency_code, currency } = region || {}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
<fieldset className="flex flex-col gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="existing_items"
|
||||
render={({ field: { name } }) => {
|
||||
return (
|
||||
<Form.Item className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Form.Label>
|
||||
{t("draftOrders.create.existingItemsLabel")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("draftOrders.create.existingItemsHint")}
|
||||
</Form.Hint>
|
||||
</div>
|
||||
{variants.items.length > 0 ? (
|
||||
variants.items.map((item, index) => {
|
||||
return (
|
||||
<div
|
||||
key={item.ei_id}
|
||||
className="bg-ui-bg-component shadow-elevation-card-rest divide-y rounded-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="shadow-borders-base size-fit overflow-hidden rounded-[4px]">
|
||||
<Thumbnail src={item.thumbnail} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
>
|
||||
{item.product_title}
|
||||
</Text>
|
||||
{item.sku && (
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
({item.sku})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{item.variant_title}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{getStylizedAmount(
|
||||
item.unit_price,
|
||||
currency_code!
|
||||
)}
|
||||
</Text>
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.remove"),
|
||||
onClick: () => variants.remove(index),
|
||||
icon: <Trash />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="grid grid-cols-1 gap-3 p-3 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`${name}.${index}.quantity`}
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("fields.quantity")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input
|
||||
className="!bg-ui-bg-field-component hover:!bg-ui-bg-field-component-hover"
|
||||
type="number"
|
||||
onChange={(e) =>
|
||||
onChange(Number(e.target.value))
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`${name}.${index}.custom_unit_price`}
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label optional>
|
||||
{t(
|
||||
"draftOrders.create.unitPriceOverrideLabel"
|
||||
)}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<CurrencyInput
|
||||
className="!bg-ui-bg-field-component hover:!bg-ui-bg-field-component-hover"
|
||||
code={currency_code!}
|
||||
symbol={currency?.symbol_native!}
|
||||
onValueChange={onChange}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="flex items-center justify-center px-2 py-3">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted"
|
||||
>
|
||||
{t("draftOrders.create.noExistingItemsAddedLabel")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end">
|
||||
<ConditionalTooltip
|
||||
content={t("draftOrders.create.chooseRegionTooltip")}
|
||||
showTooltip={!region}
|
||||
>
|
||||
<Button
|
||||
disabled={!region}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={() => onOpenDrawer(View.EXISTING_ITEMS)}
|
||||
>
|
||||
{t("draftOrders.create.addExistingItemsAction")}
|
||||
</Button>
|
||||
</ConditionalTooltip>
|
||||
</div>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="md:grid-grid-cols-2 grid grid-cols-1 gap-3 p-3">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="custom_items"
|
||||
render={({ field: { name } }) => {
|
||||
return (
|
||||
<Form.Item className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Form.Label>
|
||||
{t("draftOrders.create.customItemsLabel")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("draftOrders.create.customItemsHint")}
|
||||
</Form.Hint>
|
||||
</div>
|
||||
{custom.items.length > 0 ? (
|
||||
custom.items.map((item, index) => {
|
||||
return (
|
||||
<div
|
||||
key={item.ci_id}
|
||||
className="bg-ui-bg-component shadow-elevation-card-rest divide-y rounded-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.remove"),
|
||||
onClick: () => custom.remove(index),
|
||||
icon: <Trash />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset className="grid grid-cols-1 gap-3 p-3 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`${name}.${index}.quantity`}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("fields.quantity")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Input
|
||||
className="!bg-ui-bg-field-component hover:!bg-ui-bg-field-component-hover"
|
||||
type="number"
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`${name}.${index}.unit_price`}
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("fields.unitPrice")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<CurrencyInput
|
||||
className="!bg-ui-bg-field-component hover:!bg-ui-bg-field-component-hover"
|
||||
code={currency_code!}
|
||||
symbol={currency?.symbol_native!}
|
||||
onValueChange={onChange}
|
||||
{...field}
|
||||
/>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="flex items-center justify-center px-2 py-3">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted"
|
||||
>
|
||||
{t("draftOrders.create.noCustomItemsAddedLabel")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end">
|
||||
<ConditionalTooltip
|
||||
content={t("draftOrders.create.chooseRegionTooltip")}
|
||||
showTooltip={!region}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
type="button"
|
||||
disabled={!region}
|
||||
onClick={() => onOpenDrawer(View.CUSTOM_ITEMS)}
|
||||
>
|
||||
{t("draftOrders.create.addCustomItemAction")}
|
||||
</Button>
|
||||
</ConditionalTooltip>
|
||||
</div>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { Select } from "@medusajs/ui"
|
||||
import { useAdminRegions, useMedusa } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { json } from "react-router-dom"
|
||||
|
||||
import { useWatch } from "react-hook-form"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderRegionDetails = () => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
form,
|
||||
setRegion,
|
||||
variants: { rebase },
|
||||
} = useCreateDraftOrder()
|
||||
const { client } = useMedusa()
|
||||
|
||||
const existingItems = useWatch({
|
||||
control: form.control,
|
||||
name: "existing_items",
|
||||
})
|
||||
|
||||
const { regions, isLoading, isError, error } = useAdminRegions({
|
||||
limit: 1000,
|
||||
fields: "id,name,currency_code",
|
||||
})
|
||||
|
||||
const handleRebaseUnitPrices = async (regionId: string) => {
|
||||
if (!existingItems?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const { variants } = await client.admin.variants
|
||||
.list({
|
||||
region_id: regionId,
|
||||
id: existingItems.map((i) => i.variant_id),
|
||||
})
|
||||
.catch((_err) => {
|
||||
// Show toast with error message
|
||||
return { variants: [] }
|
||||
})
|
||||
|
||||
rebase(variants)
|
||||
}
|
||||
|
||||
const handleResetShippingDetails = () => {
|
||||
form.resetField("shipping_method")
|
||||
form.resetField("shipping_address")
|
||||
form.resetField("billing_address")
|
||||
}
|
||||
|
||||
const handleRegionChange = (regId: string) => {
|
||||
const region = regions?.find((r) => r.id === regId)
|
||||
|
||||
if (!region) {
|
||||
throw json({ message: "Region not found" }, 400)
|
||||
}
|
||||
|
||||
setRegion(region)
|
||||
}
|
||||
|
||||
const onValueChange = (fn: (...event: any[]) => void) => {
|
||||
return async (id: string) => {
|
||||
fn(id)
|
||||
await handleRebaseUnitPrices(id)
|
||||
handleResetShippingDetails()
|
||||
handleRegionChange(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset className="grid grid-cols-2 gap-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="region_id"
|
||||
render={({
|
||||
field: { ref, onChange, disabled, ...field },
|
||||
fieldState: { error },
|
||||
}) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label className="!h2-core">{t("fields.region")}</Form.Label>
|
||||
<Form.Hint>{t("draftOrders.create.chooseRegionHint")}</Form.Hint>
|
||||
<Form.Control>
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={onValueChange(onChange)}
|
||||
disabled={isLoading || disabled}
|
||||
>
|
||||
<Select.Trigger aria-invalid={!!error} ref={ref}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{regions?.map((r) => (
|
||||
<Select.Item key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query"
|
||||
import debounce from "lodash/debounce"
|
||||
import { useMedusa } from "medusa-react"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { ShippingOption } from "@medusajs/medusa"
|
||||
import { CurrencyInput, Heading, Input } from "@medusajs/ui"
|
||||
import { json } from "react-router-dom"
|
||||
import { Combobox } from "../../../../../../components/common/combobox"
|
||||
import { ConditionalTooltip } from "../../../../../../components/common/conditional-tooltip"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { getLocaleAmount } from "../../../../../../lib/money-amount-helpers"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderShippingMethodDetails = () => {
|
||||
const [query, setQuery] = useState("")
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("")
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { form, region } = useCreateDraftOrder()
|
||||
|
||||
// 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 { client } = useMedusa()
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage } = useInfiniteQuery(
|
||||
["shipping_options", region?.id, debouncedQuery],
|
||||
async ({ pageParam = 0 }) => {
|
||||
const res = await client.admin.shippingOptions.list({
|
||||
q: debouncedQuery || undefined,
|
||||
limit: 10,
|
||||
offset: pageParam,
|
||||
is_return: false,
|
||||
region_id: region?.id,
|
||||
})
|
||||
return res
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => {
|
||||
const moreCustomersExist =
|
||||
lastPage.count > lastPage.offset + lastPage.limit
|
||||
return moreCustomersExist ? lastPage.offset + lastPage.limit : undefined
|
||||
},
|
||||
enabled: !!region?.id,
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const createLabel = (shippingOption?: ShippingOption) => {
|
||||
if (!shippingOption) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return `${shippingOption.name} - ${getLocaleAmount(
|
||||
shippingOption.amount || 0,
|
||||
region?.currency_code!
|
||||
)}`
|
||||
}
|
||||
|
||||
const options =
|
||||
data?.pages
|
||||
.flatMap((page) => page.shipping_options)
|
||||
.map((so) => ({
|
||||
label: createLabel(so),
|
||||
value: so.id,
|
||||
})) || []
|
||||
|
||||
const handleShippingMethodChange = (optionId: string | undefined) => {
|
||||
if (!optionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const option = data?.pages
|
||||
.flatMap((page) => page.shipping_options)
|
||||
.find((so) => so.id === optionId)
|
||||
|
||||
if (!option) {
|
||||
throw json({ message: "Shipping option not found" }, 400)
|
||||
}
|
||||
|
||||
form.setValue("shipping_method.option_title", option.name, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
form.setValue("shipping_method.amount", option.amount || 0, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("fields.shipping")}</Heading>
|
||||
<fieldset className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="shipping_method.option_id"
|
||||
render={({ field: { onChange, disabled, ...field } }) => {
|
||||
return (
|
||||
<ConditionalTooltip
|
||||
showTooltip={!region}
|
||||
content={t("draftOrders.create.chooseRegionTooltip")}
|
||||
>
|
||||
<Form.Item>
|
||||
<div>
|
||||
<Form.Label>
|
||||
{t("draftOrders.create.shippingOptionLabel")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("draftOrders.create.shippingOptionHint")}
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
onChange={(val) => {
|
||||
handleShippingMethodChange(val)
|
||||
onChange(val)
|
||||
}}
|
||||
disabled={!region || disabled}
|
||||
searchValue={query}
|
||||
onSearchValueChange={setQuery}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
options={options}
|
||||
autoComplete="false"
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
</ConditionalTooltip>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="shipping_method.custom_amount"
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<ConditionalTooltip
|
||||
showTooltip={!region}
|
||||
content={t("draftOrders.create.chooseRegionTooltip")}
|
||||
>
|
||||
<Form.Item>
|
||||
<div>
|
||||
<Form.Label optional>
|
||||
{t("draftOrders.create.shippingPriceOverrideLabel")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("draftOrders.create.shippingPriceOverrideHint")}
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
{region ? (
|
||||
<CurrencyInput
|
||||
{...field}
|
||||
onValueChange={onChange}
|
||||
code={region.currency.code}
|
||||
symbol={region.currency.symbol_native}
|
||||
/>
|
||||
) : (
|
||||
<Input disabled />
|
||||
)}
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
</ConditionalTooltip>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./create-draft-order-details"
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { Button, CurrencyInput, Hint, Input, Label } from "@medusajs/ui"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
import { SplitView } from "../../../../../../components/layout/split-view"
|
||||
import { castNumber } from "../../../../../../lib/cast-number"
|
||||
import { CustomItemSchema } from "../constants"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
import { CustomItem } from "../types"
|
||||
|
||||
export const AddCustomItemDrawer = () => {
|
||||
const { region, custom } = useCreateDraftOrder()
|
||||
const { currency } = region || {}
|
||||
|
||||
const currencyCode = currency?.code || ""
|
||||
const nativeSymbol = currency?.symbol_native || ""
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [item, setItem] = useState<Partial<CustomItem>>({
|
||||
title: "",
|
||||
quantity: 1,
|
||||
unit_price: undefined,
|
||||
})
|
||||
const [errors, setErrors] = useState<z.ZodError<
|
||||
z.infer<typeof CustomItemSchema>
|
||||
> | null>(null)
|
||||
|
||||
const handleSave = () => {
|
||||
const parsed = CustomItemSchema.safeParse(item)
|
||||
|
||||
if (!parsed.success) {
|
||||
setErrors(parsed.error)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
custom.update(parsed.data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<div className="size-full flex-1 overflow-auto px-6 py-4">
|
||||
<div className="flex flex-col gap-y-4 [&>div]:flex [&>div]:flex-col [&>div]:gap-y-2">
|
||||
<div>
|
||||
<Label weight="plus" size="small">
|
||||
{t("fields.title")}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.title}
|
||||
onChange={(e) =>
|
||||
setItem((prev) => ({ ...prev, title: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<ErrorMessage errors={errors} field="title" />
|
||||
</div>
|
||||
<div>
|
||||
<Label weight="plus" size="small">
|
||||
{t("fields.quantity")}
|
||||
</Label>
|
||||
<Input
|
||||
value={item.quantity}
|
||||
type="number"
|
||||
step={1}
|
||||
onChange={(e) => {
|
||||
const val = castNumber(e.target.value)
|
||||
setItem((prev) => ({ ...prev, quantity: val }))
|
||||
}}
|
||||
/>
|
||||
<ErrorMessage errors={errors} field="quantity" />
|
||||
</div>
|
||||
<div>
|
||||
<Label weight="plus" size="small">
|
||||
{t("fields.unitPrice")}
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
code={currencyCode}
|
||||
symbol={nativeSymbol}
|
||||
value={item.unit_price}
|
||||
onValueChange={(value) => {
|
||||
setItem((prev) => ({ ...prev, unit_price: value }))
|
||||
}}
|
||||
/>
|
||||
<ErrorMessage errors={errors} field="unit_price" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 border-t p-4">
|
||||
<SplitView.Close type="button" asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</SplitView.Close>
|
||||
<Button size="small" type="button" onClick={handleSave}>
|
||||
{t("actions.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getFirstErrorMessage = (
|
||||
errors: z.ZodError<z.infer<typeof CustomItemSchema>> | null,
|
||||
field: string
|
||||
): string | null => {
|
||||
if (!errors) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fieldError = errors.errors.find((error) => error.path[0] === field)
|
||||
|
||||
return fieldError ? fieldError.message : null
|
||||
}
|
||||
|
||||
const ErrorMessage = ({
|
||||
field,
|
||||
errors,
|
||||
}: {
|
||||
errors: z.ZodError<z.infer<typeof CustomItemSchema>> | null
|
||||
field: string
|
||||
}) => {
|
||||
const message = getFirstErrorMessage(errors, field)
|
||||
|
||||
return message ? <Hint variant="error">{message}</Hint> : null
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { PricedVariant } from "@medusajs/medusa/dist/types/pricing"
|
||||
import { Button, Checkbox } from "@medusajs/ui"
|
||||
import {
|
||||
OnChangeFn,
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table"
|
||||
import { useAdminVariants } from "medusa-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { SplitView } from "../../../../../../components/layout/split-view"
|
||||
import { DataTable } from "../../../../../../components/table/data-table"
|
||||
import { MoneyAmountCell } from "../../../../../../components/table/table-cells/common/money-amount-cell"
|
||||
import { PlaceholderCell } from "../../../../../../components/table/table-cells/common/placeholder-cell"
|
||||
import { ProductCell } from "../../../../../../components/table/table-cells/product/product-cell"
|
||||
import { useDataTable } from "../../../../../../hooks/use-data-table"
|
||||
import { useProductVariantTableFilters } from "../../../../../products/product-detail/components/product-variant-section/use-variant-table-filters"
|
||||
import { useProductVariantTableQuery } from "../../../../../products/product-detail/components/product-variant-section/use-variant-table-query"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
import { ExistingItem } from "../types"
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const PREFIX = "av"
|
||||
|
||||
const initRowState = (items: ExistingItem[]): RowSelectionState => {
|
||||
return items.reduce((acc, curr) => {
|
||||
acc[curr.variant_id] = true
|
||||
return acc
|
||||
}, {} as RowSelectionState)
|
||||
}
|
||||
|
||||
export const AddVariantDrawer = () => {
|
||||
const { region, customer, variants: existing } = useCreateDraftOrder()
|
||||
const { currency_code } = region || {}
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
|
||||
initRowState(existing.items)
|
||||
)
|
||||
const [intermediate, setIntermediate] = useState<ExistingItem[]>(
|
||||
existing.items
|
||||
)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { searchParams, raw } = useProductVariantTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
})
|
||||
const { variants, count, isLoading, isError, error } = useAdminVariants({
|
||||
region_id: region?.id,
|
||||
customer_id: customer?.id,
|
||||
...searchParams,
|
||||
})
|
||||
|
||||
const updater: OnChangeFn<RowSelectionState> = (fn) => {
|
||||
const newState: RowSelectionState =
|
||||
typeof fn === "function" ? fn(rowSelection) : fn
|
||||
|
||||
const diff = Object.keys(newState).filter(
|
||||
(k) => newState[k] !== rowSelection[k]
|
||||
)
|
||||
|
||||
const addedVariants = variants?.filter((p) => diff.includes(p.id!)) ?? []
|
||||
|
||||
const newVariants: ExistingItem[] = addedVariants.map((v) => ({
|
||||
variant_id: v.id!,
|
||||
variant_title: v.title!,
|
||||
unit_price: v.original_price!,
|
||||
sku: v.sku ?? undefined,
|
||||
product_title: v.product?.title,
|
||||
thumbnail: v.product?.thumbnail ?? undefined,
|
||||
quantity: 1,
|
||||
}))
|
||||
|
||||
setIntermediate((prev) => {
|
||||
const filteredPrev = prev.filter((p) =>
|
||||
Object.keys(newState).includes(p.variant_id)
|
||||
)
|
||||
|
||||
const update = Array.from(new Set([...filteredPrev, ...newVariants]))
|
||||
return update
|
||||
})
|
||||
|
||||
setRowSelection(newState)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
existing.update(intermediate)
|
||||
}
|
||||
|
||||
const columns = useVariantTableColumns()
|
||||
const filters = useProductVariantTableFilters()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: variants || [],
|
||||
columns,
|
||||
count,
|
||||
pageSize: PAGE_SIZE,
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
getRowId: (row) => row.id!,
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
updater,
|
||||
},
|
||||
meta: {
|
||||
currencyCode: currency_code,
|
||||
},
|
||||
prefix: PREFIX,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
count={count}
|
||||
isLoading={isLoading}
|
||||
queryObject={raw}
|
||||
pageSize={PAGE_SIZE}
|
||||
filters={filters}
|
||||
orderBy={["title", "created_at", "updated_at"]}
|
||||
pagination
|
||||
search
|
||||
layout="fill"
|
||||
prefix={PREFIX}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2 border-t p-4">
|
||||
<SplitView.Close type="button" asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</SplitView.Close>
|
||||
<Button size="small" type="button" onClick={handleSave}>
|
||||
{t("actions.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<PricedVariant>()
|
||||
|
||||
const useVariantTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("product", {
|
||||
header: t("fields.product"),
|
||||
cell: ({ getValue }) => {
|
||||
const product = getValue()
|
||||
|
||||
if (!product) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
return <ProductCell product={product} />
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("options", {
|
||||
header: t("fields.variant"),
|
||||
cell: ({ getValue }) => {
|
||||
const options = getValue()
|
||||
|
||||
const displayValue = options?.map((o) => o.value).join(" · ")
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span className="truncate">{displayValue}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("sku", {
|
||||
header: t("fields.sku"),
|
||||
cell: ({ getValue }) => {
|
||||
const sku = getValue()
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span className="truncate">{sku ?? "-"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("original_price", {
|
||||
header: () => (
|
||||
<div className="flex size-full items-center justify-end overflow-hidden text-right">
|
||||
<span className="truncate">{t("fields.unitPrice")}</span>
|
||||
</div>
|
||||
),
|
||||
cell: ({ getValue, table }) => {
|
||||
const price = getValue()
|
||||
const { currencyCode } = table.options.meta as {
|
||||
currencyCode?: string
|
||||
}
|
||||
|
||||
if (!price || !currencyCode) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
return (
|
||||
<MoneyAmountCell
|
||||
align="right"
|
||||
amount={price}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { View } from "../constants"
|
||||
import { AddCustomItemDrawer } from "./add-custom-item-drawer"
|
||||
import { AddVariantDrawer } from "./add-variant-drawer"
|
||||
|
||||
export const CreateDraftOrderDrawer = ({ view }: { view: View | null }) => {
|
||||
switch (view) {
|
||||
case View.EXISTING_ITEMS:
|
||||
return <AddVariantDrawer />
|
||||
case View.CUSTOM_ITEMS:
|
||||
return <AddCustomItemDrawer />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./create-draft-order-drawer"
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Customer, Region } from "@medusajs/medusa"
|
||||
import { Button, ProgressTabs } from "@medusajs/ui"
|
||||
import { useAdminCreateDraftOrder } from "medusa-react"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { z } from "zod"
|
||||
|
||||
import { SplitView } from "../../../../../components/layout/split-view"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { getDbAmount } from "../../../../../lib/money-amount-helpers"
|
||||
|
||||
import { PricedVariant } from "@medusajs/medusa/dist/types/pricing"
|
||||
import { castNumber } from "../../../../../lib/cast-number"
|
||||
import { CreateDraftOrderSchema, View } from "./constants"
|
||||
import { CreateDraftOrderContext } from "./context"
|
||||
import { CreateDraftOrderDetails } from "./create-draft-order-details"
|
||||
import { CreateDraftOrderDrawer } from "./create-draft-order-drawer"
|
||||
import { CreateDraftOrderSummary } from "./create-draft-order-summary"
|
||||
import { CustomItem, ExistingItem } from "./types"
|
||||
|
||||
enum Tab {
|
||||
DETAILS = "details",
|
||||
SUMMARY = "summary",
|
||||
}
|
||||
|
||||
export const CreateDraftOrderForm = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [view, setView] = useState<View | null>(null)
|
||||
const [tab, setTab] = useState<Tab>(Tab.DETAILS)
|
||||
const [detailsValidated, setDetailsValidated] = useState(false)
|
||||
|
||||
const [region, setRegion] = useState<Region | null>(null)
|
||||
const [customer, setCustomer] = useState<Customer | null>(null)
|
||||
|
||||
const [sameAsShipping, setSameAsShipping] = useState(true)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const [, setSearchParams] = useSearchParams()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<z.infer<typeof CreateDraftOrderSchema>>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
region_id: "",
|
||||
shipping_method: {
|
||||
amount: "",
|
||||
custom_amount: "",
|
||||
option_id: "",
|
||||
option_title: "",
|
||||
},
|
||||
shipping_address: {
|
||||
address_1: "",
|
||||
address_2: "",
|
||||
city: "",
|
||||
company: "",
|
||||
country_code: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
phone: "",
|
||||
postal_code: "",
|
||||
province: "",
|
||||
},
|
||||
billing_address: null,
|
||||
existing_items: [],
|
||||
custom_items: [],
|
||||
customer_id: "",
|
||||
notification_order: true,
|
||||
},
|
||||
resolver: zodResolver(CreateDraftOrderSchema),
|
||||
})
|
||||
|
||||
const {
|
||||
clearErrors,
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
const {
|
||||
append: createCustomItem,
|
||||
remove: deleteCustomItem,
|
||||
fields: customItems,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "custom_items",
|
||||
keyName: "ci_id",
|
||||
})
|
||||
|
||||
const {
|
||||
append: createExistingItem,
|
||||
remove: deleteExistingItem,
|
||||
update: updateExistingItem,
|
||||
fields: existingItems,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "existing_items",
|
||||
keyName: "ei_id",
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading } = useAdminCreateDraftOrder()
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
let {
|
||||
shipping_address,
|
||||
billing_address,
|
||||
existing_items,
|
||||
custom_items,
|
||||
shipping_method,
|
||||
email,
|
||||
notification_order,
|
||||
...rest
|
||||
} = values
|
||||
|
||||
const emailValue = email || customer?.email
|
||||
|
||||
if (!emailValue) {
|
||||
form.setError("email", {
|
||||
type: "manual",
|
||||
message: "Email is required",
|
||||
})
|
||||
|
||||
form.setError("customer_id", {
|
||||
type: "manual",
|
||||
message: "Customer is required",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!billing_address) {
|
||||
billing_address = shipping_address
|
||||
}
|
||||
|
||||
const preparedExistingItems =
|
||||
existing_items?.map((item) => {
|
||||
const { custom_unit_price, variant_id, quantity } = item
|
||||
|
||||
const customUnitPriceCast = Number(custom_unit_price)
|
||||
const customUnitPriceValue = !isNaN(customUnitPriceCast)
|
||||
? getDbAmount(customUnitPriceCast, region?.currency_code!)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
variant_id,
|
||||
quantity,
|
||||
unit_price: customUnitPriceValue,
|
||||
}
|
||||
}) || []
|
||||
|
||||
const preparedCustomItems =
|
||||
custom_items?.map((item) => {
|
||||
const { unit_price, quantity, title } = item
|
||||
|
||||
const unitPriceCast = castNumber(unit_price)
|
||||
const unitPriceValue = !isNaN(unitPriceCast)
|
||||
? getDbAmount(unitPriceCast, region?.currency_code!)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
title,
|
||||
quantity,
|
||||
unit_price: unitPriceValue,
|
||||
}
|
||||
}) || []
|
||||
|
||||
const items = [...preparedExistingItems, ...preparedCustomItems]
|
||||
|
||||
const preparedShippingMethods = [
|
||||
{
|
||||
option_id: shipping_method.option_id,
|
||||
price: shipping_method.custom_amount
|
||||
? getDbAmount(
|
||||
castNumber(shipping_method.custom_amount),
|
||||
region?.currency_code!
|
||||
)
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
...rest,
|
||||
email: emailValue,
|
||||
shipping_address,
|
||||
billing_address,
|
||||
items: items,
|
||||
shipping_methods: preparedShippingMethods,
|
||||
no_notification_order: !notification_order,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ draft_order }) => {
|
||||
handleSuccess(`../${draft_order.id}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const clearItemErrors = useCallback(() => {
|
||||
clearErrors("custom_items")
|
||||
clearErrors("existing_items")
|
||||
}, [clearErrors])
|
||||
|
||||
const handleUpdateExistingItems = useCallback(
|
||||
(items: ExistingItem[]) => {
|
||||
handleUpdateEntities(
|
||||
items,
|
||||
existingItems,
|
||||
deleteExistingItem,
|
||||
createExistingItem,
|
||||
"variant_id"
|
||||
)
|
||||
|
||||
setView(null)
|
||||
setOpen(false)
|
||||
clearItemErrors()
|
||||
},
|
||||
[createExistingItem, deleteExistingItem, existingItems, clearItemErrors]
|
||||
)
|
||||
|
||||
const handleCreateCustomItem = useCallback(
|
||||
(item: CustomItem) => {
|
||||
createCustomItem(item)
|
||||
|
||||
setView(null)
|
||||
setOpen(false)
|
||||
clearItemErrors()
|
||||
},
|
||||
[createCustomItem, clearItemErrors]
|
||||
)
|
||||
|
||||
const handleOpenDrawer = (view: View) => {
|
||||
setView(view)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setView(null)
|
||||
setSearchParams(
|
||||
{},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
setOpen(open)
|
||||
}
|
||||
|
||||
const handleContinue = form.handleSubmit(() => {
|
||||
setTab(Tab.SUMMARY)
|
||||
setDetailsValidated(true)
|
||||
})
|
||||
|
||||
const handleRebaseUnitPrices = useCallback(
|
||||
(variants: PricedVariant[]) => {
|
||||
if (!variants.length) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const variant of variants) {
|
||||
const index = existingItems.findIndex(
|
||||
(item) => item.variant_id === variant.id
|
||||
)
|
||||
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
|
||||
updateExistingItem(index, {
|
||||
...existingItems[index],
|
||||
unit_price: variant.original_price!,
|
||||
})
|
||||
}
|
||||
},
|
||||
[updateExistingItem, existingItems]
|
||||
)
|
||||
|
||||
const handleTabChange = (tab: Tab) => {
|
||||
switch (tab) {
|
||||
case Tab.DETAILS:
|
||||
setDetailsValidated(false)
|
||||
setTab(tab)
|
||||
break
|
||||
case Tab.SUMMARY:
|
||||
handleContinue()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const detailsProgress = useMemo(() => {
|
||||
if (detailsValidated) {
|
||||
return "completed"
|
||||
}
|
||||
|
||||
if (isDirty) {
|
||||
return "in-progress"
|
||||
}
|
||||
|
||||
return "not-started"
|
||||
}, [detailsValidated, isDirty])
|
||||
|
||||
return (
|
||||
<CreateDraftOrderContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
custom: {
|
||||
items: customItems,
|
||||
remove: deleteCustomItem,
|
||||
update: handleCreateCustomItem,
|
||||
},
|
||||
variants: {
|
||||
items: existingItems,
|
||||
remove: deleteExistingItem,
|
||||
update: handleUpdateExistingItems,
|
||||
rebase: handleRebaseUnitPrices,
|
||||
},
|
||||
form,
|
||||
region,
|
||||
setRegion,
|
||||
customer,
|
||||
setCustomer,
|
||||
sameAsShipping,
|
||||
setSameAsShipping,
|
||||
onOpenDrawer: handleOpenDrawer,
|
||||
}),
|
||||
[
|
||||
customItems,
|
||||
deleteCustomItem,
|
||||
handleCreateCustomItem,
|
||||
existingItems,
|
||||
deleteExistingItem,
|
||||
handleUpdateExistingItems,
|
||||
handleRebaseUnitPrices,
|
||||
form,
|
||||
customer,
|
||||
region,
|
||||
sameAsShipping,
|
||||
]
|
||||
)}
|
||||
>
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<ProgressTabs
|
||||
value={tab}
|
||||
onValueChange={(tab) => handleTabChange(tab as Tab)}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex w-full items-center justify-between gap-x-4">
|
||||
<div className="-my-2 w-full max-w-[400px] border-l">
|
||||
<ProgressTabs.List className="grid w-full grid-cols-2">
|
||||
<ProgressTabs.Trigger
|
||||
className="w-full"
|
||||
value={Tab.DETAILS}
|
||||
status={detailsProgress}
|
||||
>
|
||||
{t("fields.details")}
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
className="w-full"
|
||||
value={Tab.SUMMARY}
|
||||
>
|
||||
{t("fields.summary")}
|
||||
</ProgressTabs.Trigger>
|
||||
</ProgressTabs.List>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
{tab === Tab.SUMMARY ? (
|
||||
<Button
|
||||
key="save-btn"
|
||||
type="submit"
|
||||
size="small"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="continue-btn"
|
||||
type="button"
|
||||
onClick={handleContinue}
|
||||
size="small"
|
||||
>
|
||||
{t("actions.continue")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="size-full overflow-hidden">
|
||||
<ProgressTabs.Content
|
||||
value={Tab.DETAILS}
|
||||
className="size-full overflow-hidden"
|
||||
>
|
||||
<SplitView open={open} onOpenChange={handleOpenChange}>
|
||||
<SplitView.Content>
|
||||
<CreateDraftOrderDetails />
|
||||
</SplitView.Content>
|
||||
<SplitView.Drawer>
|
||||
<CreateDraftOrderDrawer view={view} />
|
||||
</SplitView.Drawer>
|
||||
</SplitView>
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content
|
||||
value={Tab.SUMMARY}
|
||||
className="flex h-full w-full flex-col items-center overflow-hidden"
|
||||
>
|
||||
<CreateDraftOrderSummary />
|
||||
</ProgressTabs.Content>
|
||||
</RouteFocusModal.Body>
|
||||
</ProgressTabs>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
</CreateDraftOrderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdateEntities = <T,>(
|
||||
newEntities: T[],
|
||||
existingEntities: T[],
|
||||
deleteEntity: (indices: number[]) => void,
|
||||
createEntity: (entities: T[]) => void,
|
||||
entityIdKey: keyof T
|
||||
) => {
|
||||
const newEntitiesIdMap = newEntities.map((e) => e[entityIdKey])
|
||||
const existingEntitiesIdMap = existingEntities.map((e) => e[entityIdKey])
|
||||
|
||||
const indicesToDelete = existingEntities.reduce((acc, e, i) => {
|
||||
if (!newEntitiesIdMap.includes(e[entityIdKey])) {
|
||||
acc.push(i)
|
||||
}
|
||||
return acc
|
||||
}, [] as number[])
|
||||
|
||||
const entitiesToAdd = newEntities.filter(
|
||||
(e) => !existingEntitiesIdMap.includes(e[entityIdKey])
|
||||
)
|
||||
|
||||
deleteEntity(indicesToDelete)
|
||||
createEntity(entitiesToAdd)
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Text } from "@medusajs/ui"
|
||||
|
||||
import { Divider } from "../../../../../../components/common/divider"
|
||||
import { castNumber } from "../../../../../../lib/cast-number"
|
||||
import {
|
||||
getDbAmount,
|
||||
getLocaleAmount,
|
||||
} from "../../../../../../lib/money-amount-helpers"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderCustomItemsSummary = () => {
|
||||
const { form, region } = useCreateDraftOrder()
|
||||
const { currency_code } = region || {}
|
||||
|
||||
const items = form.getValues("custom_items") || []
|
||||
|
||||
if (!items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{items.map((item, index) => {
|
||||
const price = item.unit_price
|
||||
? getDbAmount(castNumber(item.unit_price), currency_code!)
|
||||
: item.unit_price
|
||||
const subtotal = price * item.quantity
|
||||
|
||||
return (
|
||||
<div key={index} className="grid grid-cols-2 items-start gap-4">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="text-ui-fg-base"
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<div className="grid grid-cols-3 items-center gap-x-4">
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<Text size="small">
|
||||
{getLocaleAmount(price, currency_code!)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="min-w-[27px] text-right">
|
||||
<Text>
|
||||
<span className="tabular-nums">{item.quantity}</span>x
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Text size="small">
|
||||
{getLocaleAmount(subtotal, currency_code!)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Divider variant="dashed" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { Avatar, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { getFormattedAddress } from "../../../../../../lib/addresses"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderCustomerSummary = () => {
|
||||
const { form, customer, sameAsShipping, region } = useCreateDraftOrder()
|
||||
const { countries } = region || {}
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const shippingAddress = form.getValues("shipping_address")
|
||||
const shippingAddressCountry = countries?.find(
|
||||
(c) => c.iso_2 === shippingAddress?.country_code
|
||||
)
|
||||
|
||||
const billingAddress = form.getValues("billing_address")
|
||||
const billingAddressCountry = countries?.find(
|
||||
(c) => c.iso_2 === billingAddress?.country_code
|
||||
)
|
||||
|
||||
const email = form.getValues("email")
|
||||
const phone =
|
||||
shippingAddress?.phone || billingAddress?.phone || customer?.phone
|
||||
|
||||
const { first_name, last_name } = customer || shippingAddress || {}
|
||||
const name = [first_name, last_name].filter(Boolean).join(" ")
|
||||
const fallback = name
|
||||
? name[0].toUpperCase()
|
||||
: email?.[0].toUpperCase() || "?"
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle grid grid-cols-1 gap-2">
|
||||
{customer && (
|
||||
<div className="grid grid-cols-2">
|
||||
<Text size="small" leading="compact">
|
||||
{t("fields.id")}
|
||||
</Text>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Avatar fallback={fallback} size="2xsmall" />
|
||||
<Text size="small" leading="compact">
|
||||
{name || email}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2">
|
||||
<Text size="small" leading="compact">
|
||||
{t("fields.email")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact">
|
||||
{email}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
<Text size="small" leading="compact">
|
||||
{t("fields.phone")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact">
|
||||
{phone || "-"}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
<Text size="small" leading="compact">
|
||||
{t("addresses.shippingAddress.label")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact">
|
||||
{getFormattedAddress({
|
||||
address: { ...shippingAddress, country: shippingAddressCountry },
|
||||
}).map((line, i) => {
|
||||
return (
|
||||
<span key={i} className="break-words">
|
||||
{line}
|
||||
<br />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
<Text size="small" leading="compact">
|
||||
{t("addresses.billingAddress.label")}
|
||||
</Text>
|
||||
{sameAsShipping ? (
|
||||
<Text size="small" leading="compact" className="text-ui-fg-muted">
|
||||
{t("addresses.billingAddress.sameAsShipping")}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="small" leading="compact">
|
||||
{getFormattedAddress({
|
||||
address: { ...billingAddress, country: billingAddressCountry },
|
||||
}).map((line, i) => {
|
||||
return (
|
||||
<span key={i} className="break-words">
|
||||
{line}
|
||||
<br />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { Switch } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Form } from "../../../../../../components/common/form"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderFieldsSummary = () => {
|
||||
const { t } = useTranslation()
|
||||
const { form } = useCreateDraftOrder()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="notification_order"
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Form.Label>
|
||||
{t("draftOrders.create.sendNotificationLabel")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Switch
|
||||
{...field}
|
||||
onCheckedChange={onChange}
|
||||
checked={value}
|
||||
/>
|
||||
</Form.Control>
|
||||
</div>
|
||||
<Form.Hint>
|
||||
{t("draftOrders.create.sendNotificationHint")}
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { Divider } from "../../../../../../components/common/divider"
|
||||
import { CreateDraftOrderCustomItemsSummary } from "./create-draft-order-custom-items-summary"
|
||||
import { CreateDraftOrderCustomerSummary } from "./create-draft-order-customer-summary"
|
||||
import { CreateDraftOrderFieldsSummary } from "./create-draft-order-fields-summary"
|
||||
import { CreateDraftOrderTotalSummary } from "./create-draft-order-total-summary"
|
||||
import { CreateDraftOrderVariantItemsSummary } from "./create-draft-order-variant-items-summary"
|
||||
|
||||
export const CreateDraftOrderSummary = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center overflow-auto p-16">
|
||||
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
|
||||
<Heading>{t("fields.summary")}</Heading>
|
||||
<CreateDraftOrderCustomerSummary />
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Divider variant="dashed" />
|
||||
<CreateDraftOrderVariantItemsSummary />
|
||||
<CreateDraftOrderCustomItemsSummary />
|
||||
<CreateDraftOrderTotalSummary />
|
||||
<Divider variant="dashed" />
|
||||
</div>
|
||||
<CreateDraftOrderFieldsSummary />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Divider } from "../../../../../../components/common/divider"
|
||||
import { castNumber } from "../../../../../../lib/cast-number"
|
||||
import {
|
||||
getDbAmount,
|
||||
getLocaleAmount,
|
||||
getStylizedAmount,
|
||||
} from "../../../../../../lib/money-amount-helpers"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
import { CustomItem, ExistingItem, ShippingMethod } from "../types"
|
||||
|
||||
export const CreateDraftOrderTotalSummary = () => {
|
||||
const { t } = useTranslation()
|
||||
const { form, region } = useCreateDraftOrder()
|
||||
const { currency_code } = region || {}
|
||||
|
||||
const variantItems = form.getValues("existing_items") || []
|
||||
const variantItemsSubtotal = variantItems.reduce((acc, item) => {
|
||||
const amount = getExistingItemSubtotal(item, currency_code!)
|
||||
|
||||
return (acc += amount)
|
||||
}, 0)
|
||||
|
||||
const customItems = form.getValues("custom_items") || []
|
||||
const customItemsSubtotal = customItems.reduce((acc, item) => {
|
||||
const amount = getCustomItemSubtotal(item, currency_code!)
|
||||
|
||||
return (acc += amount)
|
||||
}, 0)
|
||||
|
||||
const count = variantItems.length + customItems.length
|
||||
const subtotal = variantItemsSubtotal + customItemsSubtotal
|
||||
|
||||
const shippingMethod = form.getValues("shipping_method")
|
||||
const shippingSubtotal = getShippingSubtotal(shippingMethod, currency_code!)
|
||||
|
||||
const total = subtotal + shippingSubtotal
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Text size="small" leading="compact">
|
||||
{t("fields.subtotal")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="text-right">
|
||||
{t("general.items", { count })}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="text-right">
|
||||
{getLocaleAmount(subtotal, currency_code!)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Text size="small" leading="compact">
|
||||
{t("fields.shipping")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="text-right">
|
||||
{shippingMethod.option_title}
|
||||
</Text>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
{shippingMethod.custom_amount && (
|
||||
<Text size="small" className="text-ui-fg-muted line-through">
|
||||
{getLocaleAmount(
|
||||
castNumber(shippingMethod.amount || 0),
|
||||
currency_code!
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="small" leading="compact" className="text-right">
|
||||
{getLocaleAmount(shippingSubtotal, currency_code!)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider variant="dashed" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Text size="small" leading="compact">
|
||||
{t("fields.totalExclTax")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact" className="text-right">
|
||||
{getStylizedAmount(total, currency_code!)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getExistingItemSubtotal = (item: ExistingItem, currency_code: string) => {
|
||||
if (item.custom_unit_price) {
|
||||
const customUnitPrice = castNumber(item.custom_unit_price)
|
||||
return getDbAmount(customUnitPrice, currency_code) * item.quantity
|
||||
}
|
||||
|
||||
return item.unit_price * item.quantity
|
||||
}
|
||||
|
||||
const getCustomItemSubtotal = (item: CustomItem, currency_code: string) => {
|
||||
return getDbAmount(castNumber(item.unit_price), currency_code) * item.quantity
|
||||
}
|
||||
|
||||
const getShippingSubtotal = (
|
||||
shippingMethod: ShippingMethod,
|
||||
currency_code: string
|
||||
) => {
|
||||
if (shippingMethod.custom_amount) {
|
||||
const customAmount = castNumber(shippingMethod.custom_amount)
|
||||
return getDbAmount(customAmount, currency_code)
|
||||
}
|
||||
|
||||
if (shippingMethod.amount) {
|
||||
const amount =
|
||||
typeof shippingMethod.amount === "string"
|
||||
? Number(shippingMethod.amount.replace(",", "."))
|
||||
: shippingMethod.amount
|
||||
|
||||
return amount
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { Copy, Text } from "@medusajs/ui"
|
||||
|
||||
import { Divider } from "../../../../../../components/common/divider"
|
||||
import { Thumbnail } from "../../../../../../components/common/thumbnail"
|
||||
import { castNumber } from "../../../../../../lib/cast-number"
|
||||
import {
|
||||
getDbAmount,
|
||||
getLocaleAmount,
|
||||
} from "../../../../../../lib/money-amount-helpers"
|
||||
import { useCreateDraftOrder } from "../hooks"
|
||||
|
||||
export const CreateDraftOrderVariantItemsSummary = () => {
|
||||
const { form, region } = useCreateDraftOrder()
|
||||
const { currency_code } = region || {}
|
||||
|
||||
const items = form.getValues("existing_items") || []
|
||||
|
||||
if (!items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{items.map((item) => {
|
||||
const price = item.custom_unit_price
|
||||
? getDbAmount(castNumber(item.custom_unit_price), currency_code!)
|
||||
: item.unit_price
|
||||
const subtotal = price * item.quantity
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.variant_id}
|
||||
className="grid grid-cols-2 items-start gap-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.product_title}
|
||||
</Text>
|
||||
{item.sku && (
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Text size="small">{item.sku}</Text>
|
||||
<Copy content={item.sku} className="text-ui-fg-muted" />
|
||||
</div>
|
||||
)}
|
||||
<Text size="small">{item.variant_title}</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 items-center gap-x-4">
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
{item.custom_unit_price && (
|
||||
<Text size="small" className="text-ui-fg-muted line-through">
|
||||
{getLocaleAmount(item.unit_price, currency_code!)}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="small">
|
||||
{getLocaleAmount(price, currency_code!)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="min-w-[27px] text-right">
|
||||
<Text>
|
||||
<span className="tabular-nums">{item.quantity}</span>x
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Text size="small">
|
||||
{getLocaleAmount(subtotal, currency_code!)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Divider variant="dashed" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./create-draft-order-summary"
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react"
|
||||
import { CreateDraftOrderContext } from "./context"
|
||||
|
||||
export const useCreateDraftOrder = () => {
|
||||
const context = useContext(CreateDraftOrderContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useCreateDraftOrder must be used within a CreateDraftOrderProvider"
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./create-draft-order-form"
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { Customer, Region } from "@medusajs/medusa"
|
||||
import { FieldArrayWithId, UseFormReturn } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { PricedVariant } from "@medusajs/medusa/dist/types/pricing"
|
||||
import {
|
||||
CreateDraftOrderSchema,
|
||||
CustomItemSchema,
|
||||
ExistingItemSchema,
|
||||
ShippingMethodSchema,
|
||||
View,
|
||||
} from "./constants"
|
||||
|
||||
export type ExistingItem = z.infer<typeof ExistingItemSchema>
|
||||
export type CustomItem = z.infer<typeof CustomItemSchema>
|
||||
export type ShippingMethod = z.infer<typeof ShippingMethodSchema>
|
||||
|
||||
export type CreateDraftOrderContextValue = {
|
||||
form: UseFormReturn<z.infer<typeof CreateDraftOrderSchema>>
|
||||
region: Region | null
|
||||
setRegion: (region: Region | null) => void
|
||||
customer: Customer | null
|
||||
setCustomer: (customer: Customer | null) => void
|
||||
sameAsShipping: boolean
|
||||
setSameAsShipping: (sameAsShipping: boolean) => void
|
||||
variants: {
|
||||
items: FieldArrayWithId<
|
||||
z.infer<typeof CreateDraftOrderSchema>,
|
||||
"existing_items",
|
||||
"ei_id"
|
||||
>[]
|
||||
remove: (index: number) => void
|
||||
update: (items: ExistingItem[]) => void
|
||||
rebase: (variants: PricedVariant[]) => void
|
||||
}
|
||||
custom: {
|
||||
items: FieldArrayWithId<
|
||||
z.infer<typeof CreateDraftOrderSchema>,
|
||||
"custom_items",
|
||||
"ci_id"
|
||||
>[]
|
||||
remove: (index: number) => void
|
||||
update: (items: CustomItem) => void
|
||||
}
|
||||
onOpenDrawer: (view: View) => void
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { CreateDraftOrderForm } from "./components/create-draft-order-form/create-draft-order-form"
|
||||
|
||||
export const DraftOrderCreate = () => {
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<CreateDraftOrderForm />
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DraftOrderCreate as Component } from "./draft-order-create"
|
||||
+98
-3
@@ -5,6 +5,7 @@ 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 { Divider } from "../../../../../components/common/divider"
|
||||
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
||||
import {
|
||||
getLocaleAmount,
|
||||
@@ -51,6 +52,69 @@ const Header = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const CustomItem = ({
|
||||
item,
|
||||
currencyCode,
|
||||
reservation,
|
||||
}: {
|
||||
item: {
|
||||
id: string
|
||||
title: string
|
||||
unit_price: number
|
||||
subtotal: number
|
||||
quantity: number
|
||||
}
|
||||
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"
|
||||
>
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="text-ui-fg-base"
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<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 Item = ({
|
||||
item,
|
||||
currencyCode,
|
||||
@@ -129,9 +193,19 @@ const ItemBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
|
||||
throw error
|
||||
}
|
||||
|
||||
const variantBasedItems = draftOrder.cart.items.filter(
|
||||
(i) => i.variant_id !== null
|
||||
)
|
||||
|
||||
const customItems = draftOrder.cart.items.filter((i) => i.variant_id === null)
|
||||
|
||||
const showDivider = variantBasedItems.length > 0 && customItems.length > 0
|
||||
|
||||
console.log("customItems", customItems)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{draftOrder.cart.items.map((item) => {
|
||||
{variantBasedItems.map((item) => {
|
||||
const reservation = reservations
|
||||
? reservations.find((r) => r.line_item_id === item.id)
|
||||
: null
|
||||
@@ -145,6 +219,27 @@ const ItemBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{showDivider && <Divider variant="dashed" />}
|
||||
{customItems.map((item) => {
|
||||
const reservation = reservations
|
||||
? reservations.find((r) => r.line_item_id === item.id)
|
||||
: null
|
||||
|
||||
return (
|
||||
<CustomItem
|
||||
key={item.id}
|
||||
item={{
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
unit_price: item.unit_price,
|
||||
subtotal: item.unit_price * item.quantity,
|
||||
quantity: item.quantity,
|
||||
}}
|
||||
currencyCode={draftOrder.cart.region.currency_code}
|
||||
reservation={reservation}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -194,7 +289,7 @@ const CostBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Calculate tax rate since it's not included in the cart
|
||||
const taxRate = calculateCartTaxRate(draftOrder.cart)
|
||||
const taxRate = calculateCartTaxRate(draftOrder.cart).toFixed(2)
|
||||
|
||||
return (
|
||||
<div className="text-ui-fg-subtle flex flex-col gap-y-2 px-6 py-4">
|
||||
@@ -236,7 +331,7 @@ const CostBreakdown = ({ draftOrder }: { draftOrder: DraftOrder }) => {
|
||||
/>
|
||||
<Cost
|
||||
label={t("fields.tax")}
|
||||
secondaryValue={`${taxRate || 0}%`}
|
||||
secondaryValue={`${taxRate || 0} %`}
|
||||
value={
|
||||
draftOrder.cart.tax_total
|
||||
? getLocaleAmount(
|
||||
|
||||
+2
@@ -1,9 +1,11 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { DraftOrderListTable } from "./components/draft-order-list-table"
|
||||
|
||||
export const DraftOrderList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<DraftOrderListTable />
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user