fix(dashboard): improve inventory level location management (#13589)
**What** - add InfiniteList on location selection for inventory level -> fixes issue with location pagination - fix removal of location level for an inventory item - refresh the levels table when locations are updated - add search input for filtering locations --- CLOSES CORE-1208
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/dashboard": patch
|
||||
---
|
||||
|
||||
fix(dashboard): improve inventory level location management
|
||||
@@ -241,6 +241,9 @@ export const useBatchInventoryItemLocationLevels = (
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryItemLevelsQueryKeys.list({ inventoryItemId }),
|
||||
})
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
|
||||
@@ -3111,6 +3111,9 @@
|
||||
"quantityAcrossLocations": {
|
||||
"type": "string"
|
||||
},
|
||||
"levelDeleted": {
|
||||
"type": "string"
|
||||
},
|
||||
"create": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3327,6 +3330,7 @@
|
||||
"deleteWarning",
|
||||
"editItemDetails",
|
||||
"quantityAcrossLocations",
|
||||
"levelDeleted",
|
||||
"create",
|
||||
"reservation",
|
||||
"adjustInventory",
|
||||
|
||||
@@ -832,6 +832,7 @@
|
||||
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone.",
|
||||
"editItemDetails": "Edit item details",
|
||||
"quantityAcrossLocations": "{{quantity}} across {{locations}} locations",
|
||||
"levelDeleted": "Inventory level deleted successfully.",
|
||||
"create": {
|
||||
"title": "Create Inventory Item",
|
||||
"details": "Details",
|
||||
|
||||
+7
@@ -49,9 +49,16 @@ export const useLocationListTableColumns = () => {
|
||||
level.location_id
|
||||
)
|
||||
|
||||
toast.success(t("inventory.levelDeleted"))
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryItemsQueryKeys.lists(),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryItemLevelsQueryKeys.list({
|
||||
inventoryItemId: level.inventory_item_id,
|
||||
}),
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: inventoryItemsQueryKeys.detail(level.inventory_item_id),
|
||||
})
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
import { Checkbox, Text, clx } from "@medusajs/ui"
|
||||
|
||||
import { StockLocationDTO } from "@medusajs/types"
|
||||
import { HttpTypes } from "@medusajs/types"
|
||||
|
||||
type LocationItemProps = {
|
||||
selected: boolean
|
||||
onSelect: (selected: boolean) => void
|
||||
location: StockLocationDTO
|
||||
location: HttpTypes.AdminStockLocation
|
||||
}
|
||||
|
||||
export const LocationItem = ({
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Input } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
type LocationSearchInputProps = {
|
||||
onSearchChange: (search: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export const LocationSearchInput = ({
|
||||
onSearchChange,
|
||||
placeholder,
|
||||
}: LocationSearchInputProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onSearchChange(searchValue)
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchValue, onSearchChange])
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder || t("general.search")}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
+129
-142
@@ -1,111 +1,75 @@
|
||||
import * as zod from "zod"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { AdminInventoryItem, AdminStockLocation } from "@medusajs/types"
|
||||
import {
|
||||
AdminInventoryItem,
|
||||
AdminStockLocation,
|
||||
HttpTypes,
|
||||
} from "@medusajs/types"
|
||||
import { Button, Text, toast } from "@medusajs/ui"
|
||||
import { useFieldArray, useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
import { RouteDrawer, useRouteModal } from "../../../../../../components/modals"
|
||||
import { useBatchInventoryItemLocationLevels } from "../../../../../../hooks/api/inventory"
|
||||
import { sdk } from "../../../../../../lib/client"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { KeyboundForm } from "../../../../../../components/utilities/keybound-form"
|
||||
import { useMemo, useState } from "react"
|
||||
import { LocationItem } from "./location-item"
|
||||
import { LocationSearchInput } from "./location-search-input"
|
||||
import { InfiniteList } from "../../../../../../components/common/infinite-list/infinite-list"
|
||||
import { useStockLocations } from "../../../../../../hooks/api/stock-locations"
|
||||
|
||||
type EditInventoryItemAttributeFormProps = {
|
||||
item: AdminInventoryItem
|
||||
locations: AdminStockLocation[]
|
||||
}
|
||||
|
||||
const EditInventoryItemAttributesSchema = z.object({
|
||||
locations: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
location_id: z.string(),
|
||||
selected: z.boolean(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const getDefaultValues = (
|
||||
allLocations: AdminStockLocation[],
|
||||
existingLevels: Set<string>
|
||||
) => {
|
||||
return {
|
||||
locations: allLocations.map((location) => ({
|
||||
...location,
|
||||
location_id: location.id,
|
||||
selected: existingLevels.has(location.id),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export const ManageLocationsForm = ({
|
||||
item,
|
||||
locations,
|
||||
}: EditInventoryItemAttributeFormProps) => {
|
||||
const existingLocationLevels = useMemo(
|
||||
() => new Set(item.location_levels?.map((l) => l.location_id) ?? []),
|
||||
item.location_levels
|
||||
[item.location_levels]
|
||||
)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [selectedLocationIds, setSelectedLocationIds] = useState<Set<string>>(
|
||||
existingLocationLevels
|
||||
)
|
||||
|
||||
const form = useForm<zod.infer<typeof EditInventoryItemAttributesSchema>>({
|
||||
defaultValues: getDefaultValues(locations, existingLocationLevels),
|
||||
resolver: zodResolver(EditInventoryItemAttributesSchema),
|
||||
})
|
||||
const { count } = useStockLocations({ limit: 1, fields: "id" })
|
||||
|
||||
const { fields: locationFields, update: updateField } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "locations",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue(
|
||||
"locations",
|
||||
getDefaultValues(locations, existingLocationLevels).locations
|
||||
)
|
||||
}, [existingLocationLevels, locations])
|
||||
const handleLocationSelect = (locationId: string, selected: boolean) => {
|
||||
setSelectedLocationIds((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (selected) {
|
||||
newSet.add(locationId)
|
||||
} else {
|
||||
newSet.delete(locationId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const { mutateAsync } = useBatchInventoryItemLocationLevels(item.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async ({ locations }) => {
|
||||
// Changes in selected locations
|
||||
const [selectedLocations, unselectedLocations] = locations.reduce(
|
||||
(acc, location) => {
|
||||
// If the location is not changed do nothing
|
||||
if (
|
||||
(!location.selected &&
|
||||
!existingLocationLevels.has(location.location_id)) ||
|
||||
(location.selected &&
|
||||
existingLocationLevels.has(location.location_id))
|
||||
) {
|
||||
return acc
|
||||
}
|
||||
|
||||
if (location.selected) {
|
||||
acc[0].push(location.location_id)
|
||||
} else {
|
||||
acc[1].push(location.location_id)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
[[], []] as [string[], string[]]
|
||||
const handleSubmit = async () => {
|
||||
const toCreate = Array.from(selectedLocationIds).filter(
|
||||
(id) => !existingLocationLevels.has(id)
|
||||
)
|
||||
|
||||
if (selectedLocations.length === 0 && unselectedLocations.length === 0) {
|
||||
return handleSuccess()
|
||||
}
|
||||
const toDeleteLocations = Array.from(existingLocationLevels).filter(
|
||||
(id) => !selectedLocationIds.has(id)
|
||||
)
|
||||
|
||||
const toDelete = toDeleteLocations
|
||||
.map((id) => item.location_levels?.find((l) => l.location_id === id)?.id)
|
||||
.filter(Boolean) as unknown as string[]
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
create: selectedLocations.map((location_id) => ({
|
||||
create: toCreate.map((location_id) => ({
|
||||
location_id,
|
||||
})),
|
||||
delete: unselectedLocations,
|
||||
delete: toDelete,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -117,80 +81,103 @@ export const ManageLocationsForm = ({
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<KeyboundForm
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-4 overflow-auto">
|
||||
<div className="text-ui-fg-subtle shadow-elevation-card-rest grid grid-rows-2 divide-y rounded-lg border">
|
||||
<div className="grid grid-cols-2 divide-x">
|
||||
<Text className="px-2 py-1.5" size="small" leading="compact">
|
||||
{t("fields.title")}
|
||||
</Text>
|
||||
<Text className="px-2 py-1.5" size="small" leading="compact">
|
||||
{item.title ?? "-"}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 divide-x">
|
||||
<Text className="px-2 py-1.5" size="small" leading="compact">
|
||||
{t("fields.sku")}
|
||||
</Text>
|
||||
<Text className="px-2 py-1.5" size="small" leading="compact">
|
||||
{item.sku}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{t("locations.domain")}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-4 overflow-auto">
|
||||
<div className="text-ui-fg-subtle shadow-elevation-card-rest grid grid-rows-2 divide-y rounded-lg border">
|
||||
<div className="grid grid-cols-2 divide-x">
|
||||
<Text className="px-2 py-1.5" size="small" leading="compact">
|
||||
{t("fields.title")}
|
||||
</Text>
|
||||
<Text className="px-2 py-1.5" size="small" leading="compact">
|
||||
{item.title ?? "-"}
|
||||
</Text>
|
||||
<div className="text-ui-fg-subtle flex w-full justify-between">
|
||||
<Text size="small" leading="compact">
|
||||
{t("locations.selectLocations")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact">
|
||||
{"("}
|
||||
{t("general.countOfTotalSelected", {
|
||||
count: locationFields.filter((l) => l.selected).length,
|
||||
total: locations.length,
|
||||
})}
|
||||
{")"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
{locationFields.map((location, idx) => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 divide-x">
|
||||
<Text className="px-2 py-1.5" size="small" leading="compact">
|
||||
{t("fields.sku")}
|
||||
</Text>
|
||||
<Text className="px-2 py-1.5" size="small" leading="compact">
|
||||
{item.sku}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{t("locations.domain")}
|
||||
</Text>
|
||||
<div className="text-ui-fg-subtle flex w-full justify-between">
|
||||
<Text size="small" leading="compact">
|
||||
{t("locations.selectLocations")}
|
||||
</Text>
|
||||
<Text size="small" leading="compact">
|
||||
{"("}
|
||||
{t("general.countOfTotalSelected", {
|
||||
count: selectedLocationIds.size,
|
||||
total: count,
|
||||
})}
|
||||
{")"}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LocationSearchInput
|
||||
onSearchChange={setSearchQuery}
|
||||
placeholder={t("general.search")}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<InfiniteList<
|
||||
HttpTypes.AdminStockLocationListResponse,
|
||||
HttpTypes.AdminStockLocation,
|
||||
HttpTypes.AdminStockLocationListParams
|
||||
>
|
||||
queryKey={["stock-locations", searchQuery]}
|
||||
queryFn={async (params) => {
|
||||
const response = await sdk.admin.stockLocation.list({
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
...(searchQuery && { q: searchQuery }),
|
||||
})
|
||||
return response
|
||||
}}
|
||||
responseKey="stock_locations"
|
||||
renderItem={(location) => (
|
||||
<LocationItem
|
||||
selected={location.selected}
|
||||
location={location as any}
|
||||
onSelect={() =>
|
||||
updateField(idx, {
|
||||
...location,
|
||||
selected: !location.selected,
|
||||
})
|
||||
selected={selectedLocationIds.has(location.id)}
|
||||
location={location}
|
||||
onSelect={(selected) =>
|
||||
handleLocationSelect(location.id, selected)
|
||||
}
|
||||
key={location.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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={false}>
|
||||
{t("actions.save")}
|
||||
)}
|
||||
renderEmpty={() => (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{searchQuery
|
||||
? t("locations.noLocationsFound")
|
||||
: t("locations.noLocationsFound")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
pageSize={20}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</KeyboundForm>
|
||||
</RouteDrawer.Form>
|
||||
</RouteDrawer.Close>
|
||||
<Button onClick={handleSubmit} size="small" isLoading={false}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,14 +33,14 @@ export type ValidateInventoryLevelsDeleteStepInput = {
|
||||
* inventory levels have reserved or incoming items, or the force
|
||||
* flag is not set and the inventory levels have stocked items, the
|
||||
* step will throw an error.
|
||||
*
|
||||
*
|
||||
* :::note
|
||||
*
|
||||
*
|
||||
* You can retrieve an inventory level's details using [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query),
|
||||
* or [useQueryGraphStep](https://docs.medusajs.com/resources/references/medusa-workflows/steps/useQueryGraphStep).
|
||||
*
|
||||
*
|
||||
* :::
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const data = validateInventoryLevelsDelete({
|
||||
* inventoryLevels: [
|
||||
@@ -108,10 +108,10 @@ export const deleteInventoryLevelsWorkflowId =
|
||||
/**
|
||||
* This workflow deletes one or more inventory levels. It's used by the
|
||||
* [Delete Inventory Levels Admin API Route](https://docs.medusajs.com/api/admin#inventory-items_deleteinventoryitemsidlocationlevelslocation_id).
|
||||
*
|
||||
*
|
||||
* You can use this workflow within your own customizations or custom workflows, allowing you
|
||||
* to delete inventory levels in your custom flows.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* const { result } = await deleteInventoryLevelsWorkflow(container)
|
||||
* .run({
|
||||
@@ -119,9 +119,9 @@ export const deleteInventoryLevelsWorkflowId =
|
||||
* id: ["iilev_123", "iilev_321"],
|
||||
* }
|
||||
* })
|
||||
*
|
||||
*
|
||||
* @summary
|
||||
*
|
||||
*
|
||||
* Delete one or more inventory levels.
|
||||
*/
|
||||
export const deleteInventoryLevelsWorkflow = createWorkflow(
|
||||
|
||||
Reference in New Issue
Block a user