feat(dashboard): rework create variant flow (#9132)
**What** - new Create variant flow which allows for pricing and inventory creation as well --- https://github.com/user-attachments/assets/75ddcf5a-0f73-40ca-b474-2189c5e2e297 --- CLOSES CC-345
This commit is contained in:
1 parent
d2c48228df
commit
26700821a2
9 files changed
+898
-422
No files matched your search
@@ -536,8 +536,9 @@
|
|||||||
"header": "Edit Variant"
|
"header": "Edit Variant"
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"header": "Create Variant"
|
"header": "Variant details"
|
||||||
},
|
},
|
||||||
|
"deleteWarning": "Are you sure you want to delete this variant?",
|
||||||
"pricesPagination": "1 - {{current}} of {{total}} prices",
|
"pricesPagination": "1 - {{current}} of {{total}} prices",
|
||||||
"tableItemAvailable": "{{availableCount}} available",
|
"tableItemAvailable": "{{availableCount}} available",
|
||||||
"tableItem_one": "{{availableCount}} available at {{locationCount}} location",
|
"tableItem_one": "{{availableCount}} available at {{locationCount}} location",
|
||||||
@@ -551,6 +552,8 @@
|
|||||||
"inventoryItems": "Go to inventory item",
|
"inventoryItems": "Go to inventory item",
|
||||||
"inventoryKit": "Show inventory items"
|
"inventoryKit": "Show inventory items"
|
||||||
},
|
},
|
||||||
|
"inventoryKit": "Inventory Kit",
|
||||||
|
"inventoryKitHint": "Does this variant consist of several inventory items?",
|
||||||
"validation": {
|
"validation": {
|
||||||
"itemId": "Please select inventory item.",
|
"itemId": "Please select inventory item.",
|
||||||
"quantity": "Quantity is required. Please input a positive number."
|
"quantity": "Quantity is required. Please input a positive number."
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import i18next from "i18next"
|
import i18next from "i18next"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { castNumber } from "./cast-number"
|
import { castNumber } from "./cast-number"
|
||||||
|
import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that an optional value is an integer.
|
* Validates that an optional value is an integer.
|
||||||
@@ -45,3 +46,37 @@ export const metadataFormSchema = z.array(
|
|||||||
isIgnored: z.boolean().optional(),
|
isIgnored: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate subset of form fields
|
||||||
|
* @param form
|
||||||
|
* @param fields
|
||||||
|
* @param schema
|
||||||
|
*/
|
||||||
|
export function partialFormValidation<TForm extends FieldValues>(
|
||||||
|
form: UseFormReturn<TForm>,
|
||||||
|
fields: FieldPath<any>[],
|
||||||
|
schema: z.ZodSchema<any>
|
||||||
|
) {
|
||||||
|
form.clearErrors(fields as any)
|
||||||
|
|
||||||
|
const values = fields.reduce((acc, key) => {
|
||||||
|
acc[key] = form.getValues(key as any)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, unknown>)
|
||||||
|
|
||||||
|
const validationResult = schema.safeParse(values)
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
validationResult.error.errors.forEach(({ path, message, code }) => {
|
||||||
|
form.setError(path.join(".") as any, {
|
||||||
|
type: code,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import * as zod from "zod"
|
||||||
|
import { optionalInt } from "../../../../../lib/validation"
|
||||||
|
|
||||||
|
export const CreateProductVariantSchema = z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
sku: z.string().optional(),
|
||||||
|
manage_inventory: z.boolean().optional(),
|
||||||
|
allow_backorder: z.boolean().optional(),
|
||||||
|
inventory_kit: z.boolean().optional(),
|
||||||
|
options: z.record(z.string()),
|
||||||
|
prices: zod
|
||||||
|
.record(zod.string(), zod.string().or(zod.number()).optional())
|
||||||
|
.optional(),
|
||||||
|
inventory: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
inventory_item_id: z.string(),
|
||||||
|
required_quantity: optionalInt,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CreateVariantDetailsSchema = CreateProductVariantSchema.pick({
|
||||||
|
title: true,
|
||||||
|
sku: true,
|
||||||
|
manage_inventory: true,
|
||||||
|
allow_backorder: true,
|
||||||
|
inventory_kit: true,
|
||||||
|
options: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CreateVariantDetailsFields = Object.keys(
|
||||||
|
CreateVariantDetailsSchema.shape
|
||||||
|
) as (keyof typeof CreateVariantDetailsSchema.shape)[]
|
||||||
|
|
||||||
|
export const CreateVariantPriceSchema = CreateProductVariantSchema.pick({
|
||||||
|
prices: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CreateVariantPriceFields = Object.keys(
|
||||||
|
CreateVariantPriceSchema.shape
|
||||||
|
) as (keyof typeof CreateVariantPriceSchema.shape)[]
|
||||||
+330
-412
@@ -1,116 +1,246 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { Button, Heading, Input, Switch } from "@medusajs/ui"
|
import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
|
||||||
import { useForm } from "react-hook-form"
|
import { useFieldArray, useForm, useWatch } from "react-hook-form"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { HttpTypes } from "@medusajs/types"
|
import { AdminCreateProductVariantPrice, HttpTypes } from "@medusajs/types"
|
||||||
import { Fragment } from "react"
|
import {
|
||||||
import { Divider } from "../../../../../components/common/divider"
|
RouteDrawer,
|
||||||
import { Form } from "../../../../../components/common/form"
|
RouteFocusModal,
|
||||||
import { Combobox } from "../../../../../components/inputs/combobox"
|
useRouteModal,
|
||||||
import { CountrySelect } from "../../../../../components/inputs/country-select"
|
} from "../../../../../components/modals"
|
||||||
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
|
|
||||||
import { useCreateProductVariant } from "../../../../../hooks/api/products"
|
import { useCreateProductVariant } from "../../../../../hooks/api/products"
|
||||||
|
import {
|
||||||
|
CreateProductVariantSchema,
|
||||||
|
CreateVariantDetailsFields,
|
||||||
|
CreateVariantDetailsSchema,
|
||||||
|
CreateVariantPriceFields,
|
||||||
|
CreateVariantPriceSchema,
|
||||||
|
} from "./constants"
|
||||||
|
import DetailsTab from "./details-tab"
|
||||||
|
import PricingTab from "./pricing-tab"
|
||||||
|
import InventoryKitTab from "./inventory-kit-tab"
|
||||||
import { castNumber } from "../../../../../lib/cast-number"
|
import { castNumber } from "../../../../../lib/cast-number"
|
||||||
import { optionalInt } from "../../../../../lib/validation"
|
import { useRegions } from "../../../../../hooks/api"
|
||||||
|
import { partialFormValidation } from "../../../../../lib/validation"
|
||||||
|
|
||||||
|
enum Tab {
|
||||||
|
DETAIL = "detail",
|
||||||
|
PRICE = "price",
|
||||||
|
INVENTORY = "inventory",
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabState = Record<Tab, ProgressStatus>
|
||||||
|
|
||||||
|
const initialTabState: TabState = {
|
||||||
|
[Tab.DETAIL]: "in-progress",
|
||||||
|
[Tab.PRICE]: "not-started",
|
||||||
|
[Tab.INVENTORY]: "not-started",
|
||||||
|
}
|
||||||
|
|
||||||
type CreateProductVariantFormProps = {
|
type CreateProductVariantFormProps = {
|
||||||
product: HttpTypes.AdminProduct
|
product: HttpTypes.AdminProduct
|
||||||
isStockAndInventoryEnabled?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CreateProductVariantSchema = z.object({
|
|
||||||
title: z.string().min(1),
|
|
||||||
material: z.string().optional(),
|
|
||||||
sku: z.string().optional(),
|
|
||||||
ean: z.string().optional(),
|
|
||||||
upc: z.string().optional(),
|
|
||||||
barcode: z.string().optional(),
|
|
||||||
manage_inventory: z.boolean(),
|
|
||||||
allow_backorder: z.boolean(),
|
|
||||||
weight: optionalInt,
|
|
||||||
height: optionalInt,
|
|
||||||
width: optionalInt,
|
|
||||||
length: optionalInt,
|
|
||||||
mid_code: z.string().optional(),
|
|
||||||
hs_code: z.string().optional(),
|
|
||||||
origin_country: z.string().optional(),
|
|
||||||
options: z.record(z.string()),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const CreateProductVariantForm = ({
|
export const CreateProductVariantForm = ({
|
||||||
product,
|
product,
|
||||||
isStockAndInventoryEnabled = false,
|
|
||||||
}: CreateProductVariantFormProps) => {
|
}: CreateProductVariantFormProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { handleSuccess } = useRouteModal()
|
const { handleSuccess } = useRouteModal()
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>(Tab.DETAIL)
|
||||||
|
const [tabState, setTabState] = useState<TabState>(initialTabState)
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof CreateProductVariantSchema>>({
|
const form = useForm<z.infer<typeof CreateProductVariantSchema>>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
manage_inventory: true,
|
sku: "",
|
||||||
|
title: "",
|
||||||
|
manage_inventory: false,
|
||||||
allow_backorder: false,
|
allow_backorder: false,
|
||||||
|
inventory_kit: false,
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
resolver: zodResolver(CreateProductVariantSchema),
|
resolver: zodResolver(CreateProductVariantSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { mutateAsync, isPending: isLoading } = useCreateProductVariant(
|
const { mutateAsync, isPending } = useCreateProductVariant(product.id)
|
||||||
product.id
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit(async (data) => {
|
const { regions } = useRegions({ limit: 9999 })
|
||||||
const parseNumber = (value?: string | number) => {
|
|
||||||
if (typeof value === "undefined" || value === "") {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string") {
|
const regionsCurrencyMap = useMemo(() => {
|
||||||
return castNumber(value)
|
if (!regions?.length) {
|
||||||
}
|
return {}
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
return regions.reduce((acc, reg) => {
|
||||||
weight,
|
acc[reg.id] = reg.currency_code
|
||||||
height,
|
return acc
|
||||||
width,
|
}, {} as Record<string, string>)
|
||||||
length,
|
}, [regions])
|
||||||
allow_backorder,
|
|
||||||
manage_inventory,
|
|
||||||
sku,
|
|
||||||
ean,
|
|
||||||
upc,
|
|
||||||
barcode,
|
|
||||||
...rest
|
|
||||||
} = data
|
|
||||||
|
|
||||||
/**
|
const isManageInventoryEnabled = useWatch({
|
||||||
* If stock and inventory is not enabled, we need to send the inventory and
|
control: form.control,
|
||||||
* stock related fields to the API. If it is enabled, it should be handled
|
name: "manage_inventory",
|
||||||
* in the separate stock and inventory form.
|
})
|
||||||
*/
|
|
||||||
const conditionalPayload = !isStockAndInventoryEnabled
|
const isInventoryKitEnabled = useWatch({
|
||||||
? {
|
control: form.control,
|
||||||
sku,
|
name: "inventory_kit",
|
||||||
ean,
|
})
|
||||||
upc,
|
|
||||||
barcode,
|
const inventoryField = useFieldArray({
|
||||||
allow_backorder,
|
control: form.control,
|
||||||
manage_inventory,
|
name: `inventory`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const inventoryTabEnabled = isManageInventoryEnabled && isInventoryKitEnabled
|
||||||
|
|
||||||
|
const tabOrder = useMemo(() => {
|
||||||
|
if (inventoryTabEnabled) {
|
||||||
|
return [Tab.DETAIL, Tab.PRICE, Tab.INVENTORY] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
return [Tab.DETAIL, Tab.PRICE] as const
|
||||||
|
}, [inventoryTabEnabled])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInventoryKitEnabled && inventoryField.fields.length === 0) {
|
||||||
|
inventoryField.append({
|
||||||
|
inventory_item_id: "",
|
||||||
|
required_quantity: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [isInventoryKitEnabled])
|
||||||
|
|
||||||
|
const handleChangeTab = (update: Tab) => {
|
||||||
|
if (tab === update) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabOrder.indexOf(update) < tabOrder.indexOf(tab)) {
|
||||||
|
const isCurrentTabDirty = false // isTabDirty(tab) TODO
|
||||||
|
|
||||||
|
setTabState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tab]: isCurrentTabDirty ? prev[tab] : "not-started",
|
||||||
|
[update]: "in-progress",
|
||||||
|
}))
|
||||||
|
|
||||||
|
setTab(update)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the tabs from the current tab to the update tab including the current tab
|
||||||
|
const tabs = tabOrder.slice(0, tabOrder.indexOf(update))
|
||||||
|
|
||||||
|
// validate all the tabs from the current tab to the update tab if it fails on any of tabs then set that tab as current tab
|
||||||
|
for (const tab of tabs) {
|
||||||
|
if (tab === Tab.DETAIL) {
|
||||||
|
if (
|
||||||
|
!partialFormValidation<z.infer<typeof CreateProductVariantSchema>>(
|
||||||
|
form,
|
||||||
|
CreateVariantDetailsFields,
|
||||||
|
CreateVariantDetailsSchema
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setTabState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tab]: "in-progress",
|
||||||
|
}))
|
||||||
|
setTab(tab)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
: {}
|
|
||||||
|
setTabState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tab]: "completed",
|
||||||
|
}))
|
||||||
|
} else if (tab === Tab.PRICE) {
|
||||||
|
if (
|
||||||
|
!partialFormValidation<z.infer<typeof CreateProductVariantSchema>>(
|
||||||
|
form,
|
||||||
|
CreateVariantPriceFields,
|
||||||
|
CreateVariantPriceSchema
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setTabState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tab]: "in-progress",
|
||||||
|
}))
|
||||||
|
setTab(tab)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tab]: "completed",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTabState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tab]: "completed",
|
||||||
|
[update]: "in-progress",
|
||||||
|
}))
|
||||||
|
setTab(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNextTab = (tab: Tab) => {
|
||||||
|
if (tabOrder.indexOf(tab) + 1 >= tabOrder.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTab = tabOrder[tabOrder.indexOf(tab) + 1]
|
||||||
|
handleChangeTab(nextTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
|
const { allow_backorder, manage_inventory, sku, title } = data
|
||||||
|
|
||||||
await mutateAsync(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
weight: parseNumber(weight),
|
title,
|
||||||
height: parseNumber(height),
|
sku,
|
||||||
width: parseNumber(width),
|
allow_backorder,
|
||||||
length: parseNumber(length),
|
manage_inventory,
|
||||||
prices: [],
|
options: data.options,
|
||||||
...conditionalPayload,
|
prices: Object.entries(data.prices ?? {})
|
||||||
...rest,
|
.map(([currencyOrRegion, value]) => {
|
||||||
|
const ret: AdminCreateProductVariantPrice = {}
|
||||||
|
const amount = castNumber(value)
|
||||||
|
|
||||||
|
if (isNaN(amount) || value === "") {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currencyOrRegion.startsWith("reg_")) {
|
||||||
|
ret.rules = { region_id: currencyOrRegion }
|
||||||
|
ret.currency_code = regionsCurrencyMap[currencyOrRegion]
|
||||||
|
} else {
|
||||||
|
ret.currency_code = currencyOrRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
ret.amount = amount
|
||||||
|
|
||||||
|
return ret
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
inventory_items: (data.inventory || [])
|
||||||
|
.map((i) => {
|
||||||
|
if (!i.required_quantity || !i.inventory_item_id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...i,
|
||||||
|
required_quantity: castNumber(i.required_quantity),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -121,340 +251,128 @@ export const CreateProductVariantForm = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouteDrawer.Form form={form}>
|
<RouteFocusModal.Form form={form}>
|
||||||
<form
|
<ProgressTabs
|
||||||
onSubmit={handleSubmit}
|
value={tab}
|
||||||
className="flex size-full flex-col overflow-hidden"
|
onValueChange={(tab) => handleChangeTab(tab as Tab)}
|
||||||
|
className="flex h-full flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<RouteDrawer.Body className="flex size-full flex-col gap-y-8 overflow-auto">
|
<form
|
||||||
<div className="flex flex-col gap-y-4">
|
onSubmit={handleSubmit}
|
||||||
<Form.Field
|
className="flex h-full flex-col overflow-hidden"
|
||||||
control={form.control}
|
>
|
||||||
name="title"
|
<RouteFocusModal.Header>
|
||||||
render={({ field }) => {
|
<div className="flex w-full items-center justify-between gap-x-4">
|
||||||
return (
|
<div className="-my-2 w-full max-w-[600px] border-l">
|
||||||
<Form.Item>
|
<ProgressTabs.List className="grid w-full grid-cols-3">
|
||||||
<Form.Label>{t("fields.title")}</Form.Label>
|
<ProgressTabs.Trigger
|
||||||
<Form.Control>
|
status={tabState.detail}
|
||||||
<Input {...field} />
|
value={Tab.DETAIL}
|
||||||
</Form.Control>
|
>
|
||||||
<Form.ErrorMessage />
|
{t("priceLists.create.tabs.details")}
|
||||||
</Form.Item>
|
</ProgressTabs.Trigger>
|
||||||
)
|
<ProgressTabs.Trigger
|
||||||
}}
|
status={tabState.price}
|
||||||
/>
|
value={Tab.PRICE}
|
||||||
{product.options.map((option: any) => {
|
>
|
||||||
return (
|
{t("priceLists.create.tabs.prices")}
|
||||||
<Form.Field
|
</ProgressTabs.Trigger>
|
||||||
key={option.id}
|
{!!inventoryTabEnabled && (
|
||||||
control={form.control}
|
<ProgressTabs.Trigger
|
||||||
name={`options.${option.title}`}
|
status={tabState.inventory}
|
||||||
render={({ field: { value, onChange, ...field } }) => {
|
value={Tab.INVENTORY}
|
||||||
return (
|
>
|
||||||
<Form.Item>
|
{t("products.create.tabs.inventory")}
|
||||||
<Form.Label>{option.title}</Form.Label>
|
</ProgressTabs.Trigger>
|
||||||
<Form.Control>
|
)}
|
||||||
<Combobox
|
</ProgressTabs.List>
|
||||||
value={value}
|
|
||||||
onChange={(v) => {
|
|
||||||
onChange(v)
|
|
||||||
}}
|
|
||||||
{...field}
|
|
||||||
options={option.values.map((v: any) => ({
|
|
||||||
label: v.value,
|
|
||||||
value: v.value,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Control>
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<Divider />
|
|
||||||
{!isStockAndInventoryEnabled && (
|
|
||||||
<Fragment>
|
|
||||||
<div className="flex flex-col gap-y-8">
|
|
||||||
<div className="flex flex-col gap-y-4">
|
|
||||||
<Heading level="h2">
|
|
||||||
{t("products.variant.inventory.header")}
|
|
||||||
</Heading>
|
|
||||||
<Form.Field
|
|
||||||
control={form.control}
|
|
||||||
name="sku"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<Form.Item>
|
|
||||||
<Form.Label optional>{t("fields.sku")}</Form.Label>
|
|
||||||
<Form.Control>
|
|
||||||
<Input {...field} />
|
|
||||||
</Form.Control>
|
|
||||||
<Form.ErrorMessage />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Field
|
|
||||||
control={form.control}
|
|
||||||
name="ean"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<Form.Item>
|
|
||||||
<Form.Label optional>{t("fields.ean")}</Form.Label>
|
|
||||||
<Form.Control>
|
|
||||||
<Input {...field} />
|
|
||||||
</Form.Control>
|
|
||||||
<Form.ErrorMessage />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Field
|
|
||||||
control={form.control}
|
|
||||||
name="upc"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<Form.Item>
|
|
||||||
<Form.Label optional>{t("fields.upc")}</Form.Label>
|
|
||||||
<Form.Control>
|
|
||||||
<Input {...field} />
|
|
||||||
</Form.Control>
|
|
||||||
<Form.ErrorMessage />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Field
|
|
||||||
control={form.control}
|
|
||||||
name="barcode"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<Form.Item>
|
|
||||||
<Form.Label optional>
|
|
||||||
{t("fields.barcode")}
|
|
||||||
</Form.Label>
|
|
||||||
<Form.Control>
|
|
||||||
<Input {...field} />
|
|
||||||
</Form.Control>
|
|
||||||
<Form.ErrorMessage />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Form.Field
|
|
||||||
control={form.control}
|
|
||||||
name="manage_inventory"
|
|
||||||
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(
|
|
||||||
"products.variant.inventory.manageInventoryLabel"
|
|
||||||
)}
|
|
||||||
</Form.Label>
|
|
||||||
<Form.Control>
|
|
||||||
<Switch
|
|
||||||
checked={value}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onChange(!!checked)
|
|
||||||
}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</Form.Control>
|
|
||||||
</div>
|
|
||||||
<Form.Hint>
|
|
||||||
{t(
|
|
||||||
"products.variant.inventory.manageInventoryHint"
|
|
||||||
)}
|
|
||||||
</Form.Hint>
|
|
||||||
</div>
|
|
||||||
<Form.ErrorMessage />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Field
|
|
||||||
control={form.control}
|
|
||||||
name="allow_backorder"
|
|
||||||
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(
|
|
||||||
"products.variant.inventory.allowBackordersLabel"
|
|
||||||
)}
|
|
||||||
</Form.Label>
|
|
||||||
<Form.Control>
|
|
||||||
<Switch
|
|
||||||
checked={value}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onChange(!!checked)
|
|
||||||
}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</Form.Control>
|
|
||||||
</div>
|
|
||||||
<Form.Hint>
|
|
||||||
{t(
|
|
||||||
"products.variant.inventory.allowBackordersHint"
|
|
||||||
)}
|
|
||||||
</Form.Hint>
|
|
||||||
</div>
|
|
||||||
<Form.ErrorMessage />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
</div>
|
||||||
</Fragment>
|
</RouteFocusModal.Header>
|
||||||
)}
|
<RouteFocusModal.Body className="size-full overflow-hidden">
|
||||||
<div className="flex flex-col gap-y-4">
|
<ProgressTabs.Content
|
||||||
<Heading level="h2">{t("products.attributes")}</Heading>
|
className="size-full overflow-y-auto"
|
||||||
<Form.Field
|
value={Tab.DETAIL}
|
||||||
control={form.control}
|
>
|
||||||
name="material"
|
<DetailsTab form={form} product={product} />
|
||||||
render={({ field }) => {
|
</ProgressTabs.Content>
|
||||||
return (
|
<ProgressTabs.Content
|
||||||
<Form.Item>
|
className="size-full overflow-y-auto"
|
||||||
<Form.Label optional>{t("fields.material")}</Form.Label>
|
value={Tab.PRICE}
|
||||||
<Form.Control>
|
>
|
||||||
<Input {...field} />
|
<PricingTab form={form} />
|
||||||
</Form.Control>
|
</ProgressTabs.Content>
|
||||||
<Form.ErrorMessage />
|
{!!inventoryTabEnabled && (
|
||||||
</Form.Item>
|
<ProgressTabs.Content
|
||||||
)
|
className="size-full overflow-hidden"
|
||||||
}}
|
value={Tab.INVENTORY}
|
||||||
/>
|
>
|
||||||
<Form.Field
|
<InventoryKitTab form={form} />
|
||||||
control={form.control}
|
</ProgressTabs.Content>
|
||||||
name="weight"
|
)}
|
||||||
render={({ field }) => {
|
</RouteFocusModal.Body>
|
||||||
return (
|
<RouteFocusModal.Footer>
|
||||||
<Form.Item>
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
<Form.Label optional>{t("fields.weight")}</Form.Label>
|
<RouteDrawer.Close asChild>
|
||||||
<Form.Control>
|
<Button variant="secondary" size="small">
|
||||||
<Input type="number" {...field} />
|
{t("actions.cancel")}
|
||||||
</Form.Control>
|
</Button>
|
||||||
<Form.ErrorMessage />
|
</RouteDrawer.Close>
|
||||||
</Form.Item>
|
<PrimaryButton
|
||||||
)
|
tab={tab}
|
||||||
}}
|
next={handleNextTab}
|
||||||
/>
|
isLoading={isPending}
|
||||||
<Form.Field
|
inventoryTabEnabled={!!inventoryTabEnabled}
|
||||||
control={form.control}
|
/>
|
||||||
name="width"
|
</div>
|
||||||
render={({ field }) => {
|
</RouteFocusModal.Footer>
|
||||||
return (
|
</form>
|
||||||
<Form.Item>
|
</ProgressTabs>
|
||||||
<Form.Label optional>{t("fields.width")}</Form.Label>
|
</RouteFocusModal.Form>
|
||||||
<Form.Control>
|
)
|
||||||
<Input type="number" {...field} />
|
}
|
||||||
</Form.Control>
|
|
||||||
<Form.ErrorMessage />
|
type PrimaryButtonProps = {
|
||||||
</Form.Item>
|
tab: Tab
|
||||||
)
|
next: (tab: Tab) => void
|
||||||
}}
|
isLoading?: boolean
|
||||||
/>
|
inventoryTabEnabled: boolean
|
||||||
<Form.Field
|
}
|
||||||
control={form.control}
|
|
||||||
name="length"
|
const PrimaryButton = ({
|
||||||
render={({ field }) => {
|
tab,
|
||||||
return (
|
next,
|
||||||
<Form.Item>
|
isLoading,
|
||||||
<Form.Label optional>{t("fields.length")}</Form.Label>
|
inventoryTabEnabled,
|
||||||
<Form.Control>
|
}: PrimaryButtonProps) => {
|
||||||
<Input type="number" {...field} />
|
const { t } = useTranslation()
|
||||||
</Form.Control>
|
|
||||||
<Form.ErrorMessage />
|
if (
|
||||||
</Form.Item>
|
(inventoryTabEnabled && tab === Tab.INVENTORY) ||
|
||||||
)
|
(!inventoryTabEnabled && tab === Tab.PRICE)
|
||||||
}}
|
) {
|
||||||
/>
|
return (
|
||||||
<Form.Field
|
<Button
|
||||||
control={form.control}
|
key="submit-button"
|
||||||
name="height"
|
type="submit"
|
||||||
render={({ field }) => {
|
variant="primary"
|
||||||
return (
|
size="small"
|
||||||
<Form.Item>
|
isLoading={isLoading}
|
||||||
<Form.Label optional>{t("fields.height")}</Form.Label>
|
>
|
||||||
<Form.Control>
|
{t("actions.save")}
|
||||||
<Input type="number" {...field} />
|
</Button>
|
||||||
</Form.Control>
|
)
|
||||||
<Form.ErrorMessage />
|
}
|
||||||
</Form.Item>
|
|
||||||
)
|
return (
|
||||||
}}
|
<Button
|
||||||
/>
|
key="next-button"
|
||||||
<Form.Field
|
type="button"
|
||||||
control={form.control}
|
variant="primary"
|
||||||
name="mid_code"
|
size="small"
|
||||||
render={({ field }) => {
|
onClick={() => next(tab)}
|
||||||
return (
|
>
|
||||||
<Form.Item>
|
{t("actions.continue")}
|
||||||
<Form.Label optional>{t("fields.midCode")}</Form.Label>
|
</Button>
|
||||||
<Form.Control>
|
|
||||||
<Input {...field} />
|
|
||||||
</Form.Control>
|
|
||||||
<Form.ErrorMessage />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Field
|
|
||||||
control={form.control}
|
|
||||||
name="hs_code"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<Form.Item>
|
|
||||||
<Form.Label optional>{t("fields.hsCode")}</Form.Label>
|
|
||||||
<Form.Control>
|
|
||||||
<Input {...field} />
|
|
||||||
</Form.Control>
|
|
||||||
<Form.ErrorMessage />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Form.Field
|
|
||||||
control={form.control}
|
|
||||||
name="origin_country"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<Form.Item>
|
|
||||||
<Form.Label optional>
|
|
||||||
{t("fields.countryOfOrigin")}
|
|
||||||
</Form.Label>
|
|
||||||
<Form.Control>
|
|
||||||
<CountrySelect {...field} />
|
|
||||||
</Form.Control>
|
|
||||||
<Form.ErrorMessage />
|
|
||||||
</Form.Item>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</RouteDrawer.Body>
|
|
||||||
<RouteDrawer.Footer>
|
|
||||||
<div className="flex items-center justify-end gap-x-2">
|
|
||||||
<RouteDrawer.Close asChild>
|
|
||||||
<Button variant="secondary" size="small">
|
|
||||||
{t("actions.cancel")}
|
|
||||||
</Button>
|
|
||||||
</RouteDrawer.Close>
|
|
||||||
<Button type="submit" size="small" isLoading={isLoading}>
|
|
||||||
{t("actions.save")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</RouteDrawer.Footer>
|
|
||||||
</form>
|
|
||||||
</RouteDrawer.Form>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Heading, Input, Switch } from "@medusajs/ui"
|
||||||
|
import { UseFormReturn, useWatch } from "react-hook-form"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||||
|
import { CreateProductVariantSchema } from "./constants"
|
||||||
|
|
||||||
|
type DetailsTabProps = {
|
||||||
|
product: HttpTypes.AdminProduct
|
||||||
|
form: UseFormReturn<z.infer<typeof CreateProductVariantSchema>>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailsTab({ form, product }: DetailsTabProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const manageInventoryEnabled = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: "manage_inventory",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center overflow-y-auto">
|
||||||
|
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-8 py-16">
|
||||||
|
<Heading level="h1">{t("products.variant.create.header")}</Heading>
|
||||||
|
|
||||||
|
<div className="my-8 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{t("fields.title")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="sku"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label optional>{t("fields.sku")}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Input {...field} />
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{product.options.map((option: any) => (
|
||||||
|
<Form.Field
|
||||||
|
key={option.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`options.${option.title}`}
|
||||||
|
render={({ field: { value, onChange, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Label>{option.title}</Form.Label>
|
||||||
|
<Form.Control>
|
||||||
|
<Combobox
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
onChange(v)
|
||||||
|
}}
|
||||||
|
{...field}
|
||||||
|
options={option.values.map((v: any) => ({
|
||||||
|
label: v.value,
|
||||||
|
value: v.value,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-y-4">
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="manage_inventory"
|
||||||
|
render={({ field: { value, onChange, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<div className="bg-ui-bg-component shadow-elevation-card-rest flex gap-x-3 rounded-lg p-4">
|
||||||
|
<Form.Control>
|
||||||
|
<Switch
|
||||||
|
className="mt-[2px]"
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={(checked) => onChange(!!checked)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Form.Label>
|
||||||
|
{t("products.variant.inventory.manageInventoryLabel")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Hint>
|
||||||
|
{t("products.variant.inventory.manageInventoryHint")}
|
||||||
|
</Form.Hint>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="allow_backorder"
|
||||||
|
disabled={!manageInventoryEnabled}
|
||||||
|
render={({ field: { value, onChange, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<div className="bg-ui-bg-component shadow-elevation-card-rest flex gap-x-3 rounded-lg p-4">
|
||||||
|
<Form.Control>
|
||||||
|
<Switch
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={(checked) => onChange(!!checked)}
|
||||||
|
{...field}
|
||||||
|
disabled={!manageInventoryEnabled}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Form.Label>
|
||||||
|
{t("products.variant.inventory.allowBackordersLabel")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Hint>
|
||||||
|
{t("products.variant.inventory.allowBackordersHint")}
|
||||||
|
</Form.Hint>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name="inventory_kit"
|
||||||
|
render={({ field: { value, onChange, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<div className="bg-ui-bg-component shadow-elevation-card-rest flex gap-x-3 rounded-lg p-4">
|
||||||
|
<Form.Control>
|
||||||
|
<Switch
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={(checked) => onChange(!!checked)}
|
||||||
|
{...field}
|
||||||
|
disabled={!manageInventoryEnabled}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Form.Label>
|
||||||
|
{t("products.variant.inventory.inventoryKit")}
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Hint>
|
||||||
|
{t("products.variant.inventory.inventoryKitHint")}
|
||||||
|
</Form.Hint>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DetailsTab
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useFieldArray, UseFormReturn } from "react-hook-form"
|
||||||
|
import { Button, Heading, IconButton, Input, Label } from "@medusajs/ui"
|
||||||
|
|
||||||
|
import { CreateProductVariantSchema } from "./constants"
|
||||||
|
import { XMarkMini } from "@medusajs/icons"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import { useComboboxData } from "../../../../../hooks/use-combobox-data"
|
||||||
|
import { sdk } from "../../../../../lib/client"
|
||||||
|
import { Form } from "../../../../../components/common/form"
|
||||||
|
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||||
|
|
||||||
|
type InventoryKitTabProps = {
|
||||||
|
form: UseFormReturn<z.infer<typeof CreateProductVariantSchema>>
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryKitTab({ form }: InventoryKitTabProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const inventory = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: `inventory`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const inventoryFormData = inventory.fields
|
||||||
|
|
||||||
|
const items = useComboboxData({
|
||||||
|
queryKey: ["inventory_items"],
|
||||||
|
queryFn: (params) => sdk.admin.inventoryItem.list(params),
|
||||||
|
getOptions: (data) =>
|
||||||
|
data.inventory_items.map((item) => ({
|
||||||
|
label: item.title,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will mark an option as disabled if another input already selected that option
|
||||||
|
* @param option
|
||||||
|
* @param inventoryIndex
|
||||||
|
*/
|
||||||
|
const isItemOptionDisabled = (
|
||||||
|
option: (typeof items.options)[0],
|
||||||
|
inventoryIndex: number
|
||||||
|
) => {
|
||||||
|
return inventoryFormData?.some(
|
||||||
|
(i, index) =>
|
||||||
|
index != inventoryIndex && i.inventory_item_id === option.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center p-16">
|
||||||
|
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
|
||||||
|
<div id="organize" className="flex flex-col gap-y-8">
|
||||||
|
<Heading>{t("products.create.inventory.heading")}</Heading>
|
||||||
|
|
||||||
|
<div className="grid gap-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-x-4">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Form.Label>{form.getValues("title")}</Form.Label>
|
||||||
|
<Form.Hint>{t("products.create.inventory.label")}</Form.Hint>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
inventory.append({
|
||||||
|
inventory_item_id: "",
|
||||||
|
required_quantity: "",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("actions.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{inventory.fields.map((inventoryItem, inventoryIndex) => (
|
||||||
|
<li
|
||||||
|
key={inventoryItem.id}
|
||||||
|
className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-[min-content,1fr] items-center gap-1.5">
|
||||||
|
<div className="flex items-center px-2 py-1.5">
|
||||||
|
<Label
|
||||||
|
size="xsmall"
|
||||||
|
weight="plus"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
htmlFor={`inventory.${inventoryIndex}.inventory_item_id`}
|
||||||
|
>
|
||||||
|
{t("fields.item")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name={`inventory.${inventoryIndex}.inventory_item_id`}
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Control>
|
||||||
|
<Combobox
|
||||||
|
{...field}
|
||||||
|
options={items.options.map((o) => ({
|
||||||
|
...o,
|
||||||
|
disabled: isItemOptionDisabled(
|
||||||
|
o,
|
||||||
|
inventoryIndex
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
searchValue={items.searchValue}
|
||||||
|
onSearchValueChange={items.onSearchValueChange}
|
||||||
|
fetchNextPage={items.fetchNextPage}
|
||||||
|
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
|
||||||
|
placeholder={t(
|
||||||
|
"products.create.inventory.itemPlaceholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center px-2 py-1.5">
|
||||||
|
<Label
|
||||||
|
size="xsmall"
|
||||||
|
weight="plus"
|
||||||
|
className="text-ui-fg-subtle"
|
||||||
|
htmlFor={`inventory.${inventoryIndex}.required_quantity`}
|
||||||
|
>
|
||||||
|
{t("fields.quantity")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Form.Field
|
||||||
|
control={form.control}
|
||||||
|
name={`inventory.${inventoryIndex}.required_quantity`}
|
||||||
|
render={({ field: { onChange, value, ...field } }) => {
|
||||||
|
return (
|
||||||
|
<Form.Item>
|
||||||
|
<Form.Control>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="bg-ui-bg-field-component"
|
||||||
|
min={0}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
|
||||||
|
if (value === "") {
|
||||||
|
onChange(null)
|
||||||
|
} else {
|
||||||
|
onChange(Number(value))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...field}
|
||||||
|
placeholder={t(
|
||||||
|
"products.create.inventory.quantityPlaceholder"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form.Control>
|
||||||
|
<Form.ErrorMessage />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
variant="transparent"
|
||||||
|
className="text-ui-fg-muted"
|
||||||
|
onClick={() => inventory.remove(inventoryIndex)}
|
||||||
|
>
|
||||||
|
<XMarkMini />
|
||||||
|
</IconButton>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InventoryKitTab
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useMemo } from "react"
|
||||||
|
import { UseFormReturn, useWatch } from "react-hook-form"
|
||||||
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { CreateProductVariantSchema } from "./constants"
|
||||||
|
import { useRegions, useStore } from "../../../../../hooks/api"
|
||||||
|
import { usePricePreferences } from "../../../../../hooks/api/price-preferences"
|
||||||
|
import { useRouteModal } from "../../../../../components/modals"
|
||||||
|
import {
|
||||||
|
createDataGridHelper,
|
||||||
|
createDataGridPriceColumns,
|
||||||
|
DataGrid,
|
||||||
|
} from "../../../../../components/data-grid"
|
||||||
|
|
||||||
|
type PricingTabProps = {
|
||||||
|
form: UseFormReturn<z.infer<typeof CreateProductVariantSchema>>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PricingTab({ form }: PricingTabProps) {
|
||||||
|
const { store } = useStore()
|
||||||
|
const { regions } = useRegions({ limit: 9999 })
|
||||||
|
const { price_preferences: pricePreferences } = usePricePreferences({})
|
||||||
|
|
||||||
|
const { setCloseOnEscape } = useRouteModal()
|
||||||
|
|
||||||
|
const columns = useVariantPriceGridColumns({
|
||||||
|
currencies: store?.supported_currencies,
|
||||||
|
regions,
|
||||||
|
pricePreferences,
|
||||||
|
})
|
||||||
|
|
||||||
|
const variant = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGrid
|
||||||
|
columns={columns}
|
||||||
|
data={[variant]}
|
||||||
|
state={form}
|
||||||
|
onEditingChange={(editing) => setCloseOnEscape(!editing)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnHelper = createDataGridHelper<
|
||||||
|
HttpTypes.AdminProductVariant,
|
||||||
|
z.infer<typeof CreateProductVariantSchema>
|
||||||
|
>()
|
||||||
|
|
||||||
|
const useVariantPriceGridColumns = ({
|
||||||
|
currencies = [],
|
||||||
|
regions = [],
|
||||||
|
pricePreferences = [],
|
||||||
|
}: {
|
||||||
|
currencies?: HttpTypes.AdminStore["supported_currencies"]
|
||||||
|
regions?: HttpTypes.AdminRegion[]
|
||||||
|
pricePreferences?: HttpTypes.AdminPricePreference[]
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return [
|
||||||
|
columnHelper.column({
|
||||||
|
id: t("fields.title"),
|
||||||
|
header: t("fields.title"),
|
||||||
|
cell: (context) => {
|
||||||
|
const entity = context.row.original
|
||||||
|
return (
|
||||||
|
<DataGrid.ReadonlyCell context={context}>
|
||||||
|
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||||
|
<span className="truncate">{entity.title}</span>
|
||||||
|
</div>
|
||||||
|
</DataGrid.ReadonlyCell>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
disableHiding: true,
|
||||||
|
}),
|
||||||
|
...createDataGridPriceColumns<
|
||||||
|
HttpTypes.AdminProductVariant,
|
||||||
|
z.infer<typeof CreateProductVariantSchema>
|
||||||
|
>({
|
||||||
|
currencies: currencies.map((c) => c.currency_code),
|
||||||
|
regions,
|
||||||
|
pricePreferences,
|
||||||
|
getFieldName: (context, value) => {
|
||||||
|
if (context.column.id?.startsWith("currency_prices")) {
|
||||||
|
return `prices.${value}`
|
||||||
|
}
|
||||||
|
return `prices.${value}`
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}, [t, currencies, regions, pricePreferences])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PricingTab
|
||||||
+3
-9
@@ -1,13 +1,10 @@
|
|||||||
import { Heading } from "@medusajs/ui"
|
|
||||||
import { useTranslation } from "react-i18next"
|
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { RouteDrawer } from "../../../components/modals"
|
import { RouteFocusModal } from "../../../components/modals"
|
||||||
import { useProduct } from "../../../hooks/api/products"
|
import { useProduct } from "../../../hooks/api/products"
|
||||||
import { CreateProductVariantForm } from "./components/create-product-variant-form"
|
import { CreateProductVariantForm } from "./components/create-product-variant-form"
|
||||||
|
|
||||||
export const ProductCreateVariant = () => {
|
export const ProductCreateVariant = () => {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const { product, isLoading, isError, error } = useProduct(id!)
|
const { product, isLoading, isError, error } = useProduct(id!)
|
||||||
|
|
||||||
@@ -16,11 +13,8 @@ export const ProductCreateVariant = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouteDrawer>
|
<RouteFocusModal>
|
||||||
<RouteDrawer.Header>
|
|
||||||
<Heading>{t("products.variant.create.header")}</Heading>
|
|
||||||
</RouteDrawer.Header>
|
|
||||||
{!isLoading && product && <CreateProductVariantForm product={product} />}
|
{!isLoading && product && <CreateProductVariantForm product={product} />}
|
||||||
</RouteDrawer>
|
</RouteFocusModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,11 @@ export interface AdminCreateProductVariantPrice {
|
|||||||
rules?: { region_id: string } | null
|
rules?: { region_id: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminCreateProductVariantInventoryKit {
|
||||||
|
inventory_item_id: string
|
||||||
|
required_quantity?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminCreateProductVariant {
|
export interface AdminCreateProductVariant {
|
||||||
title: string
|
title: string
|
||||||
sku?: string
|
sku?: string
|
||||||
@@ -49,6 +54,7 @@ export interface AdminCreateProductVariant {
|
|||||||
material?: string
|
material?: string
|
||||||
metadata?: Record<string, unknown>
|
metadata?: Record<string, unknown>
|
||||||
prices: AdminCreateProductVariantPrice[]
|
prices: AdminCreateProductVariantPrice[]
|
||||||
|
inventory_items?: AdminCreateProductVariantInventoryKit[]
|
||||||
options?: Record<string, string>
|
options?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user