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:
Philip Korsholm
2023-03-24 15:01:31 +01:00
committed by GitHub
parent 4a7bdc917a
commit 332a9b686b
5 changed files with 200 additions and 36 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
feat(admin-ui): add errors and block receiving returns dependent on existing inventory item levels

View File

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

View File

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

View File

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

View File

@@ -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, {