feat(core-flows, dashboard, link-modules,medusa, types, utils): fulfillment shipping changes (#10902)
**What** - product <> shipping profile link - create and update product workflows/endpoints accepts shipping profile - pass shipping option id when creating fulfillment to allow overriding customer selected SO - validate shipping profile delete - dashboard - set shipping profile on product create - manage shipping profile for a product - **update the create fulfillment form** - other - fix create product form infinite rerenders --- CLOSES CMRC-831 CMRC-834 CMRC-836 CMRC-837 CMRC-838 CMRC-857 TRI-761
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from "./sidebar-link"
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ReactNode } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
import { IconAvatar } from "../icon-avatar"
|
||||
import { Text } from "@medusajs/ui"
|
||||
import { TriangleRightMini } from "@medusajs/icons"
|
||||
|
||||
export interface SidebarLinkProps {
|
||||
to: string
|
||||
labelKey: string
|
||||
descriptionKey: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export const SidebarLink = ({
|
||||
to,
|
||||
labelKey,
|
||||
descriptionKey,
|
||||
icon,
|
||||
}: SidebarLinkProps) => {
|
||||
return (
|
||||
<Link to={to} className="group outline-none">
|
||||
<div className="flex flex-col gap-2 px-2 pb-2">
|
||||
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed group-focus-visible:shadow-borders-interactive-with-active rounded-md px-4 py-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<IconAvatar>{icon}</IconAvatar>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{labelKey}
|
||||
</Text>
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{descriptionKey}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex size-7 items-center justify-center">
|
||||
<TriangleRightMini className="text-ui-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -554,6 +554,10 @@
|
||||
"label": "Discountable",
|
||||
"hint": "When unchecked, discounts will not be applied to this product"
|
||||
},
|
||||
"shipping_profile": {
|
||||
"label": "Shipping profile",
|
||||
"hint": "Connect the product to a shipping profile"
|
||||
},
|
||||
"type": {
|
||||
"label": "Type"
|
||||
},
|
||||
@@ -681,6 +685,20 @@
|
||||
"alreadyManagedWithSku": "This inventory item is already editable under {{title}} ({{sku}})."
|
||||
}
|
||||
},
|
||||
"shippingProfile": {
|
||||
"header": "Shipping configuration",
|
||||
"edit": {
|
||||
"header": "Shipping Configuration",
|
||||
"toasts": {
|
||||
"success": "Successfully updated the shipping profile for {{title}}."
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
"errors": {
|
||||
"required": "Shipping profile is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"toasts": {
|
||||
"delete": {
|
||||
"success": {
|
||||
@@ -1249,6 +1267,8 @@
|
||||
"fulfillment": {
|
||||
"cancelWarning": "You are about to cancel a fulfillment. This action cannot be undone.",
|
||||
"markAsDeliveredWarning": "You are about to mark fulfillment as delivered. This action cannot be undone.",
|
||||
"differentOptionSelected": "The selected shipping option is different from the one selected by the customer.",
|
||||
"disabledItemTooltip": "The shipping option you have selected don’t allow fulfillment of this item",
|
||||
"unfulfilledItems": "Unfulfilled Items",
|
||||
"statusLabel": "Fulfillment status",
|
||||
"statusTitle": "Fulfillment Status",
|
||||
@@ -1269,7 +1289,9 @@
|
||||
"error": {
|
||||
"wrongQuantity": "Only one item is available for fulfillment",
|
||||
"wrongQuantity_other": "Quantity should be a number between 1 and {{number}}",
|
||||
"noItems": "No items to fulfill."
|
||||
"noItems": "No items to fulfill.",
|
||||
"noShippingOption": "Shipping option is required",
|
||||
"noLocation": "Location is required"
|
||||
},
|
||||
"status": {
|
||||
"notFulfilled": "Not fulfilled",
|
||||
@@ -2749,7 +2771,9 @@
|
||||
"added": "Added",
|
||||
"removed": "Removed",
|
||||
"from": "From",
|
||||
"to": "To"
|
||||
"to": "To",
|
||||
"beaware": "Be aware",
|
||||
"loading": "Loading"
|
||||
},
|
||||
"fields": {
|
||||
"amount": "Amount",
|
||||
|
||||
@@ -102,6 +102,13 @@ export const RouteMap: RouteObject[] = [
|
||||
lazy: () =>
|
||||
import("../../routes/products/product-organization"),
|
||||
},
|
||||
{
|
||||
path: "shipping-profile",
|
||||
lazy: () =>
|
||||
import(
|
||||
"../../routes/products/product-shipping-profile"
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "media",
|
||||
lazy: () =>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IconAvatar } from "../../../components/common/icon-avatar"
|
||||
import { TwoColumnPage } from "../../../components/layout/pages"
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { LocationListHeader } from "./components/location-list-header"
|
||||
import { SidebarLink } from "../../../components/common/sidebar-link/sidebar-link"
|
||||
|
||||
export function LocationList() {
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
@@ -61,47 +62,6 @@ export function LocationList() {
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarLinkProps {
|
||||
to: string
|
||||
labelKey: string
|
||||
descriptionKey: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
const SidebarLink = ({
|
||||
to,
|
||||
labelKey,
|
||||
descriptionKey,
|
||||
icon,
|
||||
}: SidebarLinkProps) => {
|
||||
return (
|
||||
<Link to={to} className="group outline-none">
|
||||
<div className="flex flex-col gap-2 px-2 pb-2">
|
||||
<div className="shadow-elevation-card-rest bg-ui-bg-component transition-fg hover:bg-ui-bg-component-hover active:bg-ui-bg-component-pressed group-focus-visible:shadow-borders-interactive-with-active rounded-md px-4 py-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<IconAvatar>{icon}</IconAvatar>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{labelKey}
|
||||
</Text>
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-subtle"
|
||||
>
|
||||
{descriptionKey}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex size-7 items-center justify-center">
|
||||
<TriangleRightMini className="text-ui-fg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const LinksSection = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
||||
-1
@@ -2,7 +2,6 @@ import { z } from "zod"
|
||||
|
||||
export const CreateFulfillmentSchema = z.object({
|
||||
quantity: z.record(z.string(), z.number()),
|
||||
|
||||
location_id: z.string(),
|
||||
shipping_option_id: z.string().optional(),
|
||||
send_notification: z.boolean().optional(),
|
||||
|
||||
+184
-72
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { AdminOrder } from "@medusajs/types"
|
||||
import { AdminOrder, HttpTypes } from "@medusajs/types"
|
||||
import { Alert, Button, Select, Switch, toast } from "@medusajs/ui"
|
||||
import { useForm, useWatch } from "react-hook-form"
|
||||
|
||||
@@ -19,7 +19,10 @@ import { useStockLocations } from "../../../../../hooks/api/stock-locations"
|
||||
import { getFulfillableQuantity } from "../../../../../lib/order-item"
|
||||
import { CreateFulfillmentSchema } from "./constants"
|
||||
import { OrderCreateFulfillmentItem } from "./order-create-fulfillment-item"
|
||||
import { useReservationItems } from "../../../../../hooks/api"
|
||||
import {
|
||||
useReservationItems,
|
||||
useShippingOptions,
|
||||
} from "../../../../../hooks/api"
|
||||
|
||||
type OrderCreateFulfillmentFormProps = {
|
||||
order: AdminOrder
|
||||
@@ -56,30 +59,86 @@ export function OrderCreateFulfillmentForm({
|
||||
|
||||
const form = useForm<zod.infer<typeof CreateFulfillmentSchema>>({
|
||||
defaultValues: {
|
||||
quantity: fulfillableItems.reduce((acc, item) => {
|
||||
acc[item.id] = getFulfillableQuantity(item)
|
||||
return acc
|
||||
}, {} as Record<string, number>),
|
||||
quantity: fulfillableItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = getFulfillableQuantity(item)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
send_notification: !order.no_notification,
|
||||
},
|
||||
resolver: zodResolver(CreateFulfillmentSchema),
|
||||
})
|
||||
|
||||
const selectedLocationId = useWatch({
|
||||
name: "location_id",
|
||||
control: form.control,
|
||||
})
|
||||
|
||||
const { stock_locations = [] } = useStockLocations()
|
||||
|
||||
const { shipping_options = [], isLoading: isShippingOptionsLoading } =
|
||||
useShippingOptions({
|
||||
stock_location_id: selectedLocationId,
|
||||
// is_return: false, // TODO: 500 when enabled
|
||||
fields: "+service_zone.fulfillment_set.location.id",
|
||||
})
|
||||
|
||||
const shippingOptionId = useWatch({
|
||||
name: "shipping_option_id",
|
||||
control: form.control,
|
||||
})
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await createOrderFulfillment({
|
||||
location_id: data.location_id,
|
||||
// shipping_option_id: data.shipping_option_id,
|
||||
no_notification: !data.send_notification,
|
||||
items: Object.entries(data.quantity)
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([id, quantity]) => ({
|
||||
id,
|
||||
quantity,
|
||||
})),
|
||||
const selectedShippingOption = shipping_options.find(
|
||||
(o) => o.id === shippingOptionId
|
||||
)
|
||||
|
||||
if (!selectedShippingOption) {
|
||||
form.setError("shipping_option_id", {
|
||||
type: "manual",
|
||||
message: t("orders.fulfillment.error.noShippingOption"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedLocationId) {
|
||||
form.setError("location_id", {
|
||||
type: "manual",
|
||||
message: t("orders.fulfillment.error.noLocation"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const selectedShippingProfileId =
|
||||
selectedShippingOption?.shipping_profile_id
|
||||
|
||||
const itemShippingProfileMap = order.items.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = item.variant?.product?.shipping_profile?.id
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string | null>
|
||||
)
|
||||
|
||||
const payload: HttpTypes.AdminCreateOrderFulfillment = {
|
||||
location_id: selectedLocationId,
|
||||
shipping_option_id: shippingOptionId,
|
||||
no_notification: !data.send_notification,
|
||||
items: Object.entries(data.quantity)
|
||||
.filter(
|
||||
([id, value]) =>
|
||||
!!value && itemShippingProfileMap[id] === selectedShippingProfileId
|
||||
)
|
||||
.map(([id, quantity]) => ({
|
||||
id,
|
||||
quantity,
|
||||
})),
|
||||
}
|
||||
|
||||
try {
|
||||
await createOrderFulfillment(payload)
|
||||
|
||||
toast.success(t("orders.fulfillment.toast.created"))
|
||||
handleSuccess(`/orders/${order.id}`)
|
||||
@@ -89,15 +148,28 @@ export function OrderCreateFulfillmentForm({
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (stock_locations?.length) {
|
||||
form.setValue("location_id", stock_locations[0].id)
|
||||
}
|
||||
}, [stock_locations?.length])
|
||||
if (stock_locations?.length && shipping_options?.length) {
|
||||
const initialShippingOptionId =
|
||||
order.shipping_methods?.[0]?.shipping_option_id
|
||||
|
||||
const selectedLocationId = useWatch({
|
||||
name: "location_id",
|
||||
control: form.control,
|
||||
})
|
||||
if (initialShippingOptionId) {
|
||||
const shippingOption = shipping_options.find(
|
||||
(o) => o.id === initialShippingOptionId
|
||||
)
|
||||
|
||||
if (shippingOption) {
|
||||
const locationId =
|
||||
shippingOption.service_zone.fulfillment_set.location.id
|
||||
|
||||
form.setValue("location_id", locationId)
|
||||
form.setValue(
|
||||
"shipping_option_id",
|
||||
initialShippingOptionId || undefined
|
||||
)
|
||||
} // else -> TODO: what if original shipping option is deleted?
|
||||
}
|
||||
}
|
||||
}, [stock_locations?.length, shipping_options?.length])
|
||||
|
||||
const fulfilledQuantityArray = (order.items || []).map(
|
||||
(item) =>
|
||||
@@ -124,14 +196,21 @@ export function OrderCreateFulfillmentForm({
|
||||
})
|
||||
}
|
||||
|
||||
const quantityMap = itemsToFulfill.reduce((acc, item) => {
|
||||
acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO)
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
const quantityMap = itemsToFulfill.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.id] = getFulfillableQuantity(item as OrderLineItemDTO)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
form.setValue("quantity", quantityMap)
|
||||
}, [...fulfilledQuantityArray, requiresShipping])
|
||||
|
||||
const differentOptionSelected =
|
||||
shippingOptionId &&
|
||||
order.shipping_methods?.[0]?.shipping_option_id !== shippingOptionId
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<KeyboundForm
|
||||
@@ -185,48 +264,69 @@ export function OrderCreateFulfillmentForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* <div className="py-8">*/}
|
||||
{/* <Form.Field*/}
|
||||
{/* control={form.control}*/}
|
||||
{/* name="shipping_option_id"*/}
|
||||
{/* render={({ field: { onChange, ref, ...field } }) => {*/}
|
||||
{/* return (*/}
|
||||
{/* <Form.Item>*/}
|
||||
{/* <div className="flex flex-col gap-2 xl:flex-row xl:items-center">*/}
|
||||
{/* <div className="flex-1">*/}
|
||||
{/* <Form.Label>*/}
|
||||
{/* {t("fields.shippingMethod")}*/}
|
||||
{/* </Form.Label>*/}
|
||||
{/* <Form.Hint>*/}
|
||||
{/* {t("orders.fulfillment.methodDescription")}*/}
|
||||
{/* </Form.Hint>*/}
|
||||
{/* </div>*/}
|
||||
{/* <div className="flex-1">*/}
|
||||
{/* <Form.Control>*/}
|
||||
{/* <Select onValueChange={onChange} {...field}>*/}
|
||||
{/* <Select.Trigger*/}
|
||||
{/* className="bg-ui-bg-base"*/}
|
||||
{/* ref={ref}*/}
|
||||
{/* >*/}
|
||||
{/* <Select.Value />*/}
|
||||
{/* </Select.Trigger>*/}
|
||||
{/* <Select.Content>*/}
|
||||
{/* {shipping_options.map((o) => (*/}
|
||||
{/* <Select.Item key={o.id} value={o.id}>*/}
|
||||
{/* {o.name}*/}
|
||||
{/* </Select.Item>*/}
|
||||
{/* ))}*/}
|
||||
{/* </Select.Content>*/}
|
||||
{/* </Select>*/}
|
||||
{/* </Form.Control>*/}
|
||||
{/* </div>*/}
|
||||
{/* </div>*/}
|
||||
{/* <Form.ErrorMessage />*/}
|
||||
{/* </Form.Item>*/}
|
||||
{/* )*/}
|
||||
{/* }}*/}
|
||||
{/* />*/}
|
||||
{/* </div>*/}
|
||||
<div className="py-8">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="shipping_option_id"
|
||||
render={({ field: { onChange, ref, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col gap-2 xl:flex-row xl:items-center">
|
||||
<div className="flex-1">
|
||||
<Form.Label>
|
||||
{t("fields.shippingMethod")}
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
{t("orders.fulfillment.methodDescription")}
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Form.Control>
|
||||
<Select
|
||||
onValueChange={onChange}
|
||||
{...field}
|
||||
disabled={!selectedLocationId}
|
||||
>
|
||||
<Select.Trigger
|
||||
className="bg-ui-bg-base"
|
||||
ref={ref}
|
||||
>
|
||||
{isShippingOptionsLoading ? (
|
||||
<span className="text-right">
|
||||
{t("labels.loading")}...
|
||||
</span>
|
||||
) : (
|
||||
<Select.Value />
|
||||
)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{shipping_options.map((o) => (
|
||||
<Select.Item key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select>
|
||||
</Form.Control>
|
||||
</div>
|
||||
</div>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{differentOptionSelected && (
|
||||
<Alert className="mt-4 p-4" variant="warning">
|
||||
<span className="-mt-[3px] block font-semibold">
|
||||
{t("labels.beaware")}
|
||||
</span>
|
||||
<span className="text-ui-fg-muted">
|
||||
{t("orders.fulfillment.differentOptionSelected")}
|
||||
</span>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Form.Item className="mt-8">
|
||||
<Form.Label>
|
||||
@@ -238,12 +338,19 @@ export function OrderCreateFulfillmentForm({
|
||||
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{fulfillableItems.map((item) => {
|
||||
const isShippingProfileMatching =
|
||||
shipping_options.find(
|
||||
(o) => o.id === shippingOptionId
|
||||
)?.shipping_profile_id ===
|
||||
item.variant?.product?.shipping_profile?.id
|
||||
|
||||
return (
|
||||
<OrderCreateFulfillmentItem
|
||||
key={item.id}
|
||||
form={form}
|
||||
item={item}
|
||||
locationId={selectedLocationId}
|
||||
disabled={isShippingProfileMatching}
|
||||
itemReservedQuantitiesMap={
|
||||
itemReservedQuantitiesMap
|
||||
}
|
||||
@@ -305,7 +412,12 @@ export function OrderCreateFulfillmentForm({
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button size="small" type="submit" isLoading={isMutating}>
|
||||
<Button
|
||||
size="small"
|
||||
type="submit"
|
||||
isLoading={isMutating}
|
||||
disabled={!shippingOptionId}
|
||||
>
|
||||
{t("orders.fulfillment.create")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+101
-80
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { Input, Text } from "@medusajs/ui"
|
||||
import { clx, Input, Text, Tooltip } from "@medusajs/ui"
|
||||
import { UseFormReturn } from "react-hook-form"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Thumbnail } from "../../../../../components/common/thumbnail/index"
|
||||
import { useProductVariant } from "../../../../../hooks/api/products"
|
||||
import { getFulfillableQuantity } from "../../../../../lib/order-item"
|
||||
import { CreateFulfillmentSchema } from "./constants"
|
||||
import { InformationCircleSolid } from "@medusajs/icons"
|
||||
|
||||
type OrderEditItemProps = {
|
||||
item: HttpTypes.AdminOrderLineItem
|
||||
@@ -18,6 +19,7 @@ type OrderEditItemProps = {
|
||||
onItemRemove: (itemId: string) => void
|
||||
itemReservedQuantitiesMap: Map<string, number>
|
||||
form: UseFormReturn<zod.infer<typeof CreateFulfillmentSchema>>
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export function OrderCreateFulfillmentItem({
|
||||
@@ -25,6 +27,7 @@ export function OrderCreateFulfillmentItem({
|
||||
form,
|
||||
locationId,
|
||||
itemReservedQuantitiesMap,
|
||||
disabled,
|
||||
}: OrderEditItemProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -70,102 +73,120 @@ export function OrderCreateFulfillmentItem({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl ">
|
||||
<div className="flex flex-col gap-x-2 gap-y-2 border-b p-3 text-sm sm:flex-row">
|
||||
<div className="flex flex-1 items-center gap-x-3">
|
||||
<Thumbnail src={item.thumbnail} />
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
<Text className="txt-small" as="span" weight="plus">
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.variant_sku && <span>({item.variant_sku})</span>}
|
||||
</div>
|
||||
<Text as="div" className="text-ui-fg-subtle txt-small">
|
||||
{item.variant_title}
|
||||
</Text>
|
||||
<div className="bg-ui-bg-subtle shadow-elevation-card-rest my-2 rounded-xl">
|
||||
<div className="flex flex-row items-center">
|
||||
{disabled && (
|
||||
<div className="inline-flex items-center ml-4">
|
||||
<Tooltip
|
||||
content={t("orders.fulfillment.disabledItemTooltip")}
|
||||
side="top"
|
||||
>
|
||||
<InformationCircleSolid className="text-ui-tag-orange-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-1">
|
||||
<div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
|
||||
|
||||
<div className="text-small flex flex-1 flex-col">
|
||||
<span className="text-ui-fg-subtle font-medium">
|
||||
{t("orders.fulfillment.available")}
|
||||
</span>
|
||||
<span className="text-ui-fg-subtle">
|
||||
{availableQuantity || "N/A"}
|
||||
</span>
|
||||
<div
|
||||
className={clx(
|
||||
"flex flex-col flex-1 gap-x-2 gap-y-2 border-b p-3 text-sm sm:flex-row",
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-x-3">
|
||||
<Thumbnail src={item.thumbnail} />
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
<Text className="txt-small" as="span" weight="plus">
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.variant_sku && <span>({item.variant_sku})</span>}
|
||||
</div>
|
||||
<Text as="div" className="text-ui-fg-subtle txt-small">
|
||||
{item.variant_title}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-1">
|
||||
<div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="text-small flex flex-1 flex-col">
|
||||
<span className="text-ui-fg-subtle font-medium">
|
||||
{t("orders.fulfillment.inStock")}
|
||||
{t("orders.fulfillment.available")}
|
||||
</span>
|
||||
<span className="text-ui-fg-subtle">
|
||||
{inStockQuantity || "N/A"}{" "}
|
||||
{inStockQuantity && (
|
||||
<span className="font-medium text-red-500">
|
||||
-{form.getValues(`quantity.${item.id}`)}
|
||||
</span>
|
||||
)}
|
||||
{availableQuantity || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`quantity.${item.id}`}
|
||||
rules={{ required: true, min: minValue, max: maxValue }}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<Input
|
||||
className="bg-ui-bg-base txt-small w-[50px] rounded-lg text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
e.target.value === ""
|
||||
? null
|
||||
: Number(e.target.value)
|
||||
<div className="flex flex-1 items-center gap-x-1">
|
||||
<div className="mr-2 block h-[16px] w-[2px] bg-gray-200" />
|
||||
|
||||
field.onChange(val)
|
||||
<div className="flex flex-col">
|
||||
<span className="text-ui-fg-subtle font-medium">
|
||||
{t("orders.fulfillment.inStock")}
|
||||
</span>
|
||||
<span className="text-ui-fg-subtle">
|
||||
{inStockQuantity || "N/A"}{" "}
|
||||
{inStockQuantity && (
|
||||
<span className="font-medium text-red-500">
|
||||
-{form.getValues(`quantity.${item.id}`)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if (!isNaN(val)) {
|
||||
if (val < minValue || val > maxValue) {
|
||||
form.setError(`quantity.${item.id}`, {
|
||||
type: "manual",
|
||||
message: t(
|
||||
"orders.fulfillment.error.wrongQuantity",
|
||||
{
|
||||
count: maxValue,
|
||||
number: maxValue,
|
||||
}
|
||||
),
|
||||
})
|
||||
} else {
|
||||
form.clearErrors(`quantity.${item.id}`)
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name={`quantity.${item.id}`}
|
||||
rules={{ required: true, min: minValue, max: maxValue }}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<Input
|
||||
className="bg-ui-bg-base txt-small w-[50px] rounded-lg text-right [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val =
|
||||
e.target.value === ""
|
||||
? null
|
||||
: Number(e.target.value)
|
||||
|
||||
field.onChange(val)
|
||||
|
||||
if (!isNaN(val)) {
|
||||
if (val < minValue || val > maxValue) {
|
||||
form.setError(`quantity.${item.id}`, {
|
||||
type: "manual",
|
||||
message: t(
|
||||
"orders.fulfillment.error.wrongQuantity",
|
||||
{
|
||||
count: maxValue,
|
||||
number: maxValue,
|
||||
}
|
||||
),
|
||||
})
|
||||
} else {
|
||||
form.clearErrors(`quantity.${item.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className="text-ui-fg-subtle">
|
||||
/ {item.quantity} {t("fields.qty")}
|
||||
</span>
|
||||
<span className="text-ui-fg-subtle">
|
||||
/ {item.quantity} {t("fields.qty")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@ export function OrderCreateFulfillment() {
|
||||
const requiresShipping = searchParams.get("requires_shipping") === "true"
|
||||
|
||||
const { order, isLoading, isError, error } = useOrder(id!, {
|
||||
fields: "currency_code,*items,*items.variant,*shipping_address",
|
||||
fields:
|
||||
"currency_code,*items,*items.variant,+items.variant.product.shipping_profile.id,*shipping_address,+shipping_methods.shipping_option_id",
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
|
||||
+18
-5
@@ -79,10 +79,13 @@ export const ProductCreateForm = ({
|
||||
return {}
|
||||
}
|
||||
|
||||
return regions.reduce((acc, reg) => {
|
||||
acc[reg.id] = reg.currency_code
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
return regions.reduce(
|
||||
(acc, reg) => {
|
||||
acc[reg.id] = reg.currency_code
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
}, [regions])
|
||||
|
||||
/**
|
||||
@@ -178,6 +181,15 @@ export const ProductCreateForm = ({
|
||||
}
|
||||
|
||||
if (currentTab === Tab.ORGANIZE) {
|
||||
// TODO: this is temp until we add partial validation per tab
|
||||
if (!form.getValues("shipping_profile_id")) {
|
||||
form.setError("shipping_profile_id", {
|
||||
type: "required",
|
||||
message: t("products.shippingProfile.create.errors.required"),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setTab(Tab.VARIANTS)
|
||||
}
|
||||
|
||||
@@ -208,7 +220,8 @@ export const ProductCreateForm = ({
|
||||
}
|
||||
|
||||
setTabState({ ...currentState })
|
||||
}, [tab, tabState])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this effect to run when the tab changes
|
||||
}, [tab])
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
|
||||
+38
@@ -51,6 +51,16 @@ export const ProductCreateOrganizationSection = ({
|
||||
})),
|
||||
})
|
||||
|
||||
const shippingProfiles = useComboboxData({
|
||||
queryKey: ["shipping_profiles"],
|
||||
queryFn: (params) => sdk.admin.shippingProfile.list(params),
|
||||
getOptions: (data) =>
|
||||
data.shipping_profiles.map((shippingProfile) => ({
|
||||
label: shippingProfile.name,
|
||||
value: shippingProfile.id,
|
||||
})),
|
||||
})
|
||||
|
||||
const { fields, remove, replace } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "sales_channels",
|
||||
@@ -161,6 +171,34 @@ export const ProductCreateOrganizationSection = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Form.Label>{t("products.fields.shipping_profile.label")}</Form.Label>
|
||||
<Form.Hint>
|
||||
<Trans i18nKey={"products.fields.shipping_profile.hint"} />
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="shipping_profile_id"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
options={shippingProfiles.options}
|
||||
searchValue={shippingProfiles.searchValue}
|
||||
onSearchValueChange={shippingProfiles.onSearchValueChange}
|
||||
fetchNextPage={shippingProfiles.fetchNextPage}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
|
||||
@@ -64,6 +64,7 @@ export const ProductCreateSchema = z
|
||||
discountable: z.boolean(),
|
||||
type_id: z.string().optional(),
|
||||
collection_id: z.string().optional(),
|
||||
shipping_profile_id: z.string(), // TODO: require min(1) when partial validation per tab is added
|
||||
categories: z.array(z.string()),
|
||||
tags: z.array(z.string()).optional(),
|
||||
sales_channels: z
|
||||
@@ -145,6 +146,7 @@ export const PRODUCT_CREATE_FORM_DEFAULTS: Partial<
|
||||
media: [],
|
||||
categories: [],
|
||||
collection_id: "",
|
||||
shipping_profile_id: "",
|
||||
description: "",
|
||||
handle: "",
|
||||
height: "",
|
||||
|
||||
@@ -24,6 +24,7 @@ export const normalizeProductFormValues = (
|
||||
: undefined,
|
||||
images,
|
||||
collection_id: values.collection_id || undefined,
|
||||
shipping_profile_id: values.shipping_profile_id,
|
||||
categories: values.categories.map((id) => ({ id })),
|
||||
type_id: values.type_id || undefined,
|
||||
handle: values.handle || undefined,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-shipping-profile-section"
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { PencilSquare, ShoppingBag } from "@medusajs/icons"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { SidebarLink } from "../../../../../components/common/sidebar-link/sidebar-link"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
|
||||
type ProductShippingProfileSectionProps = {
|
||||
product: HttpTypes.AdminProduct & {
|
||||
shipping_profile: HttpTypes.AdminShippingProfile
|
||||
}
|
||||
}
|
||||
|
||||
export const ProductShippingProfileSection = ({
|
||||
product,
|
||||
}: ProductShippingProfileSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const shippingProfile = product.shipping_profile
|
||||
|
||||
if (!shippingProfile) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("products.shippingProfile.header")}</Heading>
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
to: "shipping-profile",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SidebarLink
|
||||
to={`/settings/locations/shipping-profiles/${shippingProfile.id}`}
|
||||
labelKey={shippingProfile.name}
|
||||
descriptionKey={shippingProfile.type}
|
||||
icon={<ShoppingBag />}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -2,5 +2,5 @@ import { getLinkedFields } from "../../../extensions"
|
||||
|
||||
export const PRODUCT_DETAIL_FIELDS = getLinkedFields(
|
||||
"product",
|
||||
"*categories,-variants"
|
||||
"*categories,*shipping_profile,-variants"
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PRODUCT_DETAIL_FIELDS } from "./constants"
|
||||
import { productLoader } from "./loader"
|
||||
|
||||
import { useDashboardExtension } from "../../../extensions"
|
||||
import { ProductShippingProfileSection } from "./components/product-shipping-profile-section"
|
||||
|
||||
export const ProductDetail = () => {
|
||||
const initialData = useLoaderData() as Awaited<
|
||||
@@ -71,6 +72,7 @@ export const ProductDetail = () => {
|
||||
</TwoColumnPage.Main>
|
||||
<TwoColumnPage.Sidebar>
|
||||
<ProductSalesChannelSection product={product} />
|
||||
<ProductShippingProfileSection product={product} />
|
||||
<ProductOrganizationSection product={product} />
|
||||
<ProductAttributeSection product={product} />
|
||||
</TwoColumnPage.Sidebar>
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./product-shipping-profile-form"
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
import { Button, toast } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { Combobox } from "../../../../../components/inputs/combobox"
|
||||
import { RouteDrawer, useRouteModal } from "../../../../../components/modals"
|
||||
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
|
||||
import { useExtendableForm } from "../../../../../extensions"
|
||||
import { useUpdateProduct } from "../../../../../hooks/api/products"
|
||||
import { useComboboxData } from "../../../../../hooks/use-combobox-data"
|
||||
import { sdk } from "../../../../../lib/client"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
type ProductShippingProfileFormProps = {
|
||||
product: HttpTypes.AdminProduct & {
|
||||
shipping_profile?: HttpTypes.AdminShippingProfile
|
||||
}
|
||||
}
|
||||
|
||||
const ProductShippingProfileSchema = zod.object({
|
||||
shipping_profile_id: zod.string(),
|
||||
})
|
||||
|
||||
export const ProductShippingProfileForm = ({
|
||||
product,
|
||||
}: ProductShippingProfileFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const shippingProfiles = useComboboxData({
|
||||
queryKey: ["shipping_profiles"],
|
||||
queryFn: (params) => sdk.admin.shippingProfile.list(params),
|
||||
getOptions: (data) =>
|
||||
data.shipping_profiles.map((shippingProfile) => ({
|
||||
label: shippingProfile.name,
|
||||
value: shippingProfile.id,
|
||||
})),
|
||||
})
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
shipping_profile_id: product.shipping_profile?.id ?? "",
|
||||
},
|
||||
resolver: zodResolver(ProductShippingProfileSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending } = useUpdateProduct(product.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
shipping_profile_id: data.shipping_profile_id,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ product }) => {
|
||||
toast.success(
|
||||
t("products.shippingProfile.edit.toasts.success", {
|
||||
title: product.title,
|
||||
})
|
||||
)
|
||||
handleSuccess()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<KeyboundForm onSubmit={handleSubmit} className="flex h-full flex-col">
|
||||
<RouteDrawer.Body>
|
||||
<div className="flex h-full flex-col gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="shipping_profile_id"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>
|
||||
{t("products.fields.shipping_profile.label")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<Combobox
|
||||
{...field}
|
||||
options={shippingProfiles.options}
|
||||
searchValue={shippingProfiles.searchValue}
|
||||
onSearchValueChange={
|
||||
shippingProfiles.onSearchValueChange
|
||||
}
|
||||
fetchNextPage={shippingProfiles.fetchNextPage}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* <FormExtensionZone fields={fields} form={form} /> */}
|
||||
</div>
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteDrawer.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button size="small" type="submit" isLoading={isPending}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</KeyboundForm>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProductShippingProfile as Component } from "./product-shipping-profile"
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
|
||||
import { RouteDrawer } from "../../../components/modals"
|
||||
import { useProduct } from "../../../hooks/api/products"
|
||||
import { PRODUCT_DETAIL_FIELDS } from "../product-detail/constants"
|
||||
import { ProductShippingProfileForm } from "./components/product-organization-form"
|
||||
|
||||
export const ProductShippingProfile = () => {
|
||||
const { id } = useParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { product, isLoading, isError, error } = useProduct(id!, {
|
||||
fields: PRODUCT_DETAIL_FIELDS,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<RouteDrawer.Title asChild>
|
||||
<Heading>{t("products.shippingProfile.edit.header")}</Heading>
|
||||
</RouteDrawer.Title>
|
||||
</RouteDrawer.Header>
|
||||
{!isLoading && product && (
|
||||
<ProductShippingProfileForm product={product} />
|
||||
)}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user