Feat(admin-ui): Request return flow warnings and errors (#3473)
**What** - Add warning to request-return modal if no inventory level exists for the combination of items that is being requested - Block receive return at location with the same condition
This commit is contained in:
5
.changeset/tricky-grapes-flow.md
Normal file
5
.changeset/tricky-grapes-flow.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
feat(admin-ui): add errors and block receiving returns dependent on existing inventory item levels
|
||||
@@ -1,8 +1,3 @@
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import { nestedForm } from "../../../utils/nested-form"
|
||||
import DiscountableForm, {
|
||||
DiscountableFormType,
|
||||
} from "../../forms/product/discountable-form"
|
||||
@@ -10,8 +5,14 @@ import GeneralForm, { GeneralFormType } from "../../forms/product/general-form"
|
||||
import OrganizeForm, {
|
||||
OrganizeFormType,
|
||||
} from "../../forms/product/organize-form"
|
||||
|
||||
import Button from "../../fundamentals/button"
|
||||
import Modal from "../../molecules/modal"
|
||||
import { Product } from "@medusajs/medusa"
|
||||
import { nestedForm } from "../../../utils/nested-form"
|
||||
import useEditProductActions from "../../../hooks/use-edit-product-actions"
|
||||
import { useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
type Props = {
|
||||
product: Product
|
||||
@@ -149,7 +150,7 @@ const getDefaultValues = (product: Product): GeneralFormWrapper => {
|
||||
? { label: product.type.value, value: product.type.id }
|
||||
: null,
|
||||
tags: product.tags ? product.tags.map((t) => t.value) : null,
|
||||
categories: product?.categories?.map((c) => c.id),
|
||||
categories: product.categories?.map((c) => c.id),
|
||||
},
|
||||
discountable: {
|
||||
value: product.discountable,
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React from "react"
|
||||
import React, { useState } from "react"
|
||||
import {
|
||||
AdminGetVariantsVariantInventoryRes,
|
||||
AdminPostReturnsReturnReceiveReq,
|
||||
InventoryLevelDTO,
|
||||
LineItem,
|
||||
Order,
|
||||
Return,
|
||||
StockLocationDTO,
|
||||
} from "@medusajs/medusa"
|
||||
import { useAdminOrder, useAdminReceiveReturn } from "medusa-react"
|
||||
import { useAdminOrder, useAdminReceiveReturn, useMedusa } from "medusa-react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useForm, useWatch } from "react-hook-form"
|
||||
import Button from "../../../../components/fundamentals/button"
|
||||
import Modal from "../../../../components/molecules/modal"
|
||||
import useNotification from "../../../../hooks/use-notification"
|
||||
@@ -36,6 +39,7 @@ export type ReceiveReturnFormType = {
|
||||
}
|
||||
|
||||
export const ReceiveReturnMenu = ({ order, returnRequest, onClose }: Props) => {
|
||||
const { client } = useMedusa()
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
const isLocationFulfillmentEnabled =
|
||||
isFeatureEnabled("inventoryService") &&
|
||||
@@ -47,6 +51,18 @@ export const ReceiveReturnMenu = ({ order, returnRequest, onClose }: Props) => {
|
||||
expand: orderRelations,
|
||||
})
|
||||
|
||||
const form = useForm<ReceiveReturnFormType>({
|
||||
defaultValues: getDefaultReceiveReturnValues(order, returnRequest),
|
||||
reValidateMode: "onBlur",
|
||||
})
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
const {
|
||||
stock_locations,
|
||||
refetch: refetchLocations,
|
||||
@@ -69,6 +85,79 @@ export const ReceiveReturnMenu = ({ order, returnRequest, onClose }: Props) => {
|
||||
label: string
|
||||
} | null>(null)
|
||||
|
||||
const itemMap = React.useMemo(() => {
|
||||
return new Map<string, LineItem>(order.items.map((i) => [i.id, i]))
|
||||
}, [order.items])
|
||||
|
||||
const [inventoryMap, setInventoryMap] = useState<
|
||||
Map<string, InventoryLevelDTO[]>
|
||||
>(new Map())
|
||||
|
||||
React.useEffect(() => {
|
||||
const getInventoryMap = async () => {
|
||||
if (!returnRequest.items?.length || !isLocationFulfillmentEnabled) {
|
||||
return new Map()
|
||||
}
|
||||
const itemInventoryList = await Promise.all(
|
||||
returnRequest.items.map(async (item) => {
|
||||
const orderItem = itemMap.get(item.item_id)
|
||||
if (!orderItem?.variant_id) {
|
||||
return undefined
|
||||
}
|
||||
return await client.admin.variants.getInventory(orderItem.variant_id)
|
||||
})
|
||||
)
|
||||
|
||||
return new Map(
|
||||
itemInventoryList
|
||||
.filter((it) => !!it)
|
||||
.map((item) => {
|
||||
const { variant } = item as AdminGetVariantsVariantInventoryRes
|
||||
return [variant.id, variant.inventory[0].location_levels]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
getInventoryMap().then((map) => {
|
||||
setInventoryMap(map)
|
||||
})
|
||||
}, [
|
||||
client.admin.variants,
|
||||
isLocationFulfillmentEnabled,
|
||||
itemMap,
|
||||
returnRequest.items,
|
||||
])
|
||||
|
||||
const { items: receiveItems } = useWatch({
|
||||
control: form.control,
|
||||
name: "receive_items",
|
||||
})
|
||||
|
||||
const noOfReturnItems = receiveItems.filter((item) => item.receive).length
|
||||
|
||||
const locationsHasInventoryLevels = React.useMemo(() => {
|
||||
if (!noOfReturnItems) {
|
||||
return true
|
||||
}
|
||||
|
||||
return receiveItems
|
||||
.map((returnItem) => {
|
||||
const item = itemMap.get(returnItem.item_id)
|
||||
if (!item?.variant_id || !returnItem.receive) {
|
||||
return true
|
||||
}
|
||||
const hasInventoryLevel = inventoryMap
|
||||
.get(item.variant_id)
|
||||
?.find((l) => l.location_id === selectedLocation?.value)
|
||||
|
||||
if (!hasInventoryLevel && selectedLocation?.value) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.every(Boolean)
|
||||
}, [receiveItems, itemMap, noOfReturnItems, inventoryMap, selectedLocation])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocationFulfillmentEnabled && stock_locations?.length) {
|
||||
const location = stock_locations.find(
|
||||
@@ -110,17 +199,6 @@ export const ReceiveReturnMenu = ({ order, returnRequest, onClose }: Props) => {
|
||||
return isRefundedClaim || Boolean(returnRequest.swap_id)
|
||||
}, [isRefundedClaim, returnRequest.swap_id])
|
||||
|
||||
const form = useForm<ReceiveReturnFormType>({
|
||||
defaultValues: getDefaultReceiveReturnValues(order, returnRequest),
|
||||
})
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isDirty },
|
||||
} = form
|
||||
|
||||
const notification = useNotification()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -218,6 +296,17 @@ export const ReceiveReturnMenu = ({ order, returnRequest, onClose }: Props) => {
|
||||
placeholder="Select Location to Return to"
|
||||
value={selectedLocation}
|
||||
isMulti={false}
|
||||
name={"location_id"}
|
||||
errors={
|
||||
locationsHasInventoryLevels
|
||||
? {}
|
||||
: {
|
||||
location_id: {
|
||||
message:
|
||||
"No inventory levels exist for the items at the selected location",
|
||||
},
|
||||
}
|
||||
}
|
||||
onChange={setSelectedLocation}
|
||||
options={
|
||||
stock_locations?.map((sl: StockLocationDTO) => ({
|
||||
@@ -247,7 +336,7 @@ export const ReceiveReturnMenu = ({ order, returnRequest, onClose }: Props) => {
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
disabled={!isDirty || isLoading}
|
||||
disabled={!isDirty || isLoading || !locationsHasInventoryLevels}
|
||||
loading={isLoading}
|
||||
>
|
||||
Save and close
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
import {
|
||||
AdminGetVariantsVariantInventoryRes,
|
||||
AdminPostOrdersOrderReturnsReq,
|
||||
LineItem as RawLineItem,
|
||||
InventoryLevelDTO,
|
||||
Order,
|
||||
LineItem as RawLineItem,
|
||||
StockLocationDTO,
|
||||
} from "@medusajs/medusa"
|
||||
import LayeredModal, {
|
||||
LayeredModalContext,
|
||||
} from "../../../../components/molecules/modal/layered-modal"
|
||||
import React, { useContext, useEffect, useState } from "react"
|
||||
import {
|
||||
useAdminRequestReturn,
|
||||
useAdminShippingOptions,
|
||||
useAdminStockLocations,
|
||||
useMedusa,
|
||||
} from "medusa-react"
|
||||
import React, { useContext, useEffect, useState } from "react"
|
||||
import Spinner from "../../../../components/atoms/spinner"
|
||||
import LayeredModal, {
|
||||
LayeredModalContext,
|
||||
} from "../../../../components/molecules/modal/layered-modal"
|
||||
|
||||
import Button from "../../../../components/fundamentals/button"
|
||||
import CheckIcon from "../../../../components/fundamentals/icons/check-icon"
|
||||
import CurrencyInput from "../../../../components/organisms/currency-input"
|
||||
import EditIcon from "../../../../components/fundamentals/icons/edit-icon"
|
||||
import IconTooltip from "../../../../components/molecules/icon-tooltip"
|
||||
import Modal from "../../../../components/molecules/modal"
|
||||
import { Option } from "../../../../types/shared"
|
||||
import RMASelectProductTable from "../../../../components/organisms/rma-select-product-table"
|
||||
import RMAShippingPrice from "../../../../components/molecules/rma-select-shipping"
|
||||
import Select from "../../../../components/molecules/select/next-select/select"
|
||||
import CurrencyInput from "../../../../components/organisms/currency-input"
|
||||
import RMASelectProductTable from "../../../../components/organisms/rma-select-product-table"
|
||||
import useNotification from "../../../../hooks/use-notification"
|
||||
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
|
||||
import { Option } from "../../../../types/shared"
|
||||
import { getErrorMessage } from "../../../../utils/error-messages"
|
||||
import Spinner from "../../../../components/atoms/spinner"
|
||||
import WarningCircleIcon from "../../../../components/fundamentals/icons/warning-circle"
|
||||
import { displayAmount } from "../../../../utils/prices"
|
||||
import { removeNullish } from "../../../../utils/remove-nullish"
|
||||
import { getAllReturnableItems } from "../utils/create-filtering"
|
||||
import { getErrorMessage } from "../../../../utils/error-messages"
|
||||
import { removeNullish } from "../../../../utils/remove-nullish"
|
||||
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
|
||||
import useNotification from "../../../../hooks/use-notification"
|
||||
|
||||
type ReturnMenuProps = {
|
||||
order: Order
|
||||
@@ -40,6 +44,7 @@ type ReturnMenuProps = {
|
||||
type LineItem = Omit<RawLineItem, "beforeInsert">
|
||||
|
||||
const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
|
||||
const { client } = useMedusa()
|
||||
const layeredModalContext = useContext(LayeredModalContext)
|
||||
const { isFeatureEnabled } = useFeatureFlag()
|
||||
const isLocationFulfillmentEnabled =
|
||||
@@ -88,6 +93,62 @@ const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
|
||||
}
|
||||
}, [order])
|
||||
|
||||
const itemMap = React.useMemo(() => {
|
||||
return new Map<string, LineItem>(order.items.map((i) => [i.id, i]))
|
||||
}, [order.items])
|
||||
|
||||
const [inventoryMap, setInventoryMap] = useState<
|
||||
Map<string, InventoryLevelDTO[]>
|
||||
>(new Map())
|
||||
|
||||
React.useEffect(() => {
|
||||
const getInventoryMap = async () => {
|
||||
if (!allItems.length || !isLocationFulfillmentEnabled) {
|
||||
return new Map()
|
||||
}
|
||||
const itemInventoryList = await Promise.all(
|
||||
allItems.map(async (item) => {
|
||||
if (!item.variant_id) {
|
||||
return undefined
|
||||
}
|
||||
return await client.admin.variants.getInventory(item.variant_id)
|
||||
})
|
||||
)
|
||||
|
||||
return new Map(
|
||||
itemInventoryList
|
||||
.filter((it) => !!it)
|
||||
.map((item) => {
|
||||
const { variant } = item as AdminGetVariantsVariantInventoryRes
|
||||
return [variant.id, variant.inventory[0].location_levels]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
getInventoryMap().then((map) => {
|
||||
setInventoryMap(map)
|
||||
})
|
||||
}, [allItems, client.admin.variants, isLocationFulfillmentEnabled])
|
||||
|
||||
const locationsHasInventoryLevels = React.useMemo(() => {
|
||||
return Object.entries(toReturn)
|
||||
.map(([itemId]) => {
|
||||
const item = itemMap.get(itemId)
|
||||
if (!item?.variant_id) {
|
||||
return true
|
||||
}
|
||||
const hasInventoryLevel = inventoryMap
|
||||
.get(item.variant_id)
|
||||
?.find((l) => l.location_id === selectedLocation?.value)
|
||||
|
||||
if (!hasInventoryLevel && selectedLocation?.value) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.every(Boolean)
|
||||
}, [toReturn, itemMap, selectedLocation?.value, inventoryMap])
|
||||
|
||||
const { isLoading: shippingLoading, shipping_options: shippingOptions } =
|
||||
useAdminShippingOptions({
|
||||
region_id: order.region_id,
|
||||
@@ -227,6 +288,16 @@ const ReturnMenu: React.FC<ReturnMenuProps> = ({ order, onDismiss }) => {
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
{!locationsHasInventoryLevels && (
|
||||
<div className="bg-orange-10 border-orange-20 rounded-rounded text-yellow-60 gap-x-base mt-4 flex border p-4">
|
||||
<div className="text-orange-40">
|
||||
<WarningCircleIcon size={20} fillType="solid" />
|
||||
</div>
|
||||
<div>
|
||||
{`The selected location does not have inventory levels for the selected items. The return can be requested but can't be received until an inventory level is created for the selected location.`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ export const getAllReturnableItems = (
|
||||
order: Omit<Order, "beforeInserts">,
|
||||
isClaim: boolean
|
||||
) => {
|
||||
console.log(JSON.stringify(order, null, 2))
|
||||
|
||||
let orderItems = order.items.reduce(
|
||||
(map, obj) =>
|
||||
map.set(obj.id, {
|
||||
|
||||
Reference in New Issue
Block a user