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:
1 parent
edf29b3bd2
commit
7af9e3224c
8 files changed
+144
-103
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/dashboard": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(dashboard): improve inventory level location management
|
||||||
@@ -241,6 +241,9 @@ export const useBatchInventoryItemLocationLevels = (
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId),
|
queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId),
|
||||||
})
|
})
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: inventoryItemLevelsQueryKeys.list({ inventoryItemId }),
|
||||||
|
})
|
||||||
options?.onSuccess?.(data, variables, context)
|
options?.onSuccess?.(data, variables, context)
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -3111,6 +3111,9 @@
|
|||||||
"quantityAcrossLocations": {
|
"quantityAcrossLocations": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"levelDeleted": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3327,6 +3330,7 @@
|
|||||||
"deleteWarning",
|
"deleteWarning",
|
||||||
"editItemDetails",
|
"editItemDetails",
|
||||||
"quantityAcrossLocations",
|
"quantityAcrossLocations",
|
||||||
|
"levelDeleted",
|
||||||
"create",
|
"create",
|
||||||
"reservation",
|
"reservation",
|
||||||
"adjustInventory",
|
"adjustInventory",
|
||||||
|
|||||||
@@ -832,6 +832,7 @@
|
|||||||
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone.",
|
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone.",
|
||||||
"editItemDetails": "Edit item details",
|
"editItemDetails": "Edit item details",
|
||||||
"quantityAcrossLocations": "{{quantity}} across {{locations}} locations",
|
"quantityAcrossLocations": "{{quantity}} across {{locations}} locations",
|
||||||
|
"levelDeleted": "Inventory level deleted successfully.",
|
||||||
"create": {
|
"create": {
|
||||||
"title": "Create Inventory Item",
|
"title": "Create Inventory Item",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
|
|||||||
+7
@@ -49,9 +49,16 @@ export const useLocationListTableColumns = () => {
|
|||||||
level.location_id
|
level.location_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
toast.success(t("inventory.levelDeleted"))
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: inventoryItemsQueryKeys.lists(),
|
queryKey: inventoryItemsQueryKeys.lists(),
|
||||||
})
|
})
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: inventoryItemLevelsQueryKeys.list({
|
||||||
|
inventoryItemId: level.inventory_item_id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: inventoryItemsQueryKeys.detail(level.inventory_item_id),
|
queryKey: inventoryItemsQueryKeys.detail(level.inventory_item_id),
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
import { Checkbox, Text, clx } from "@medusajs/ui"
|
import { Checkbox, Text, clx } from "@medusajs/ui"
|
||||||
|
|
||||||
import { StockLocationDTO } from "@medusajs/types"
|
import { HttpTypes } from "@medusajs/types"
|
||||||
|
|
||||||
type LocationItemProps = {
|
type LocationItemProps = {
|
||||||
selected: boolean
|
selected: boolean
|
||||||
onSelect: (selected: boolean) => void
|
onSelect: (selected: boolean) => void
|
||||||
location: StockLocationDTO
|
location: HttpTypes.AdminStockLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LocationItem = ({
|
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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+88
-101
@@ -1,111 +1,75 @@
|
|||||||
import * as zod from "zod"
|
import {
|
||||||
|
AdminInventoryItem,
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
AdminStockLocation,
|
||||||
import { AdminInventoryItem, AdminStockLocation } from "@medusajs/types"
|
HttpTypes,
|
||||||
|
} from "@medusajs/types"
|
||||||
import { Button, Text, toast } from "@medusajs/ui"
|
import { Button, Text, toast } from "@medusajs/ui"
|
||||||
import { useFieldArray, useForm } from "react-hook-form"
|
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { z } from "zod"
|
|
||||||
import { RouteDrawer, useRouteModal } from "../../../../../../components/modals"
|
import { RouteDrawer, useRouteModal } from "../../../../../../components/modals"
|
||||||
import { useBatchInventoryItemLocationLevels } from "../../../../../../hooks/api/inventory"
|
import { useBatchInventoryItemLocationLevels } from "../../../../../../hooks/api/inventory"
|
||||||
|
import { sdk } from "../../../../../../lib/client"
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { KeyboundForm } from "../../../../../../components/utilities/keybound-form"
|
|
||||||
import { LocationItem } from "./location-item"
|
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 = {
|
type EditInventoryItemAttributeFormProps = {
|
||||||
item: AdminInventoryItem
|
item: AdminInventoryItem
|
||||||
locations: AdminStockLocation[]
|
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 = ({
|
export const ManageLocationsForm = ({
|
||||||
item,
|
item,
|
||||||
locations,
|
|
||||||
}: EditInventoryItemAttributeFormProps) => {
|
}: EditInventoryItemAttributeFormProps) => {
|
||||||
const existingLocationLevels = useMemo(
|
const existingLocationLevels = useMemo(
|
||||||
() => new Set(item.location_levels?.map((l) => l.location_id) ?? []),
|
() => new Set(item.location_levels?.map((l) => l.location_id) ?? []),
|
||||||
item.location_levels
|
[item.location_levels]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { handleSuccess } = useRouteModal()
|
const { handleSuccess } = useRouteModal()
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const form = useForm<zod.infer<typeof EditInventoryItemAttributesSchema>>({
|
const [selectedLocationIds, setSelectedLocationIds] = useState<Set<string>>(
|
||||||
defaultValues: getDefaultValues(locations, existingLocationLevels),
|
existingLocationLevels
|
||||||
resolver: zodResolver(EditInventoryItemAttributesSchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { fields: locationFields, update: updateField } = useFieldArray({
|
|
||||||
control: form.control,
|
|
||||||
name: "locations",
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.setValue(
|
|
||||||
"locations",
|
|
||||||
getDefaultValues(locations, existingLocationLevels).locations
|
|
||||||
)
|
)
|
||||||
}, [existingLocationLevels, locations])
|
|
||||||
|
const { count } = useStockLocations({ limit: 1, fields: "id" })
|
||||||
|
|
||||||
|
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 { mutateAsync } = useBatchInventoryItemLocationLevels(item.id)
|
||||||
|
|
||||||
const handleSubmit = form.handleSubmit(async ({ locations }) => {
|
const handleSubmit = async () => {
|
||||||
// Changes in selected locations
|
const toCreate = Array.from(selectedLocationIds).filter(
|
||||||
const [selectedLocations, unselectedLocations] = locations.reduce(
|
(id) => !existingLocationLevels.has(id)
|
||||||
(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[]]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (selectedLocations.length === 0 && unselectedLocations.length === 0) {
|
const toDeleteLocations = Array.from(existingLocationLevels).filter(
|
||||||
return handleSuccess()
|
(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(
|
await mutateAsync(
|
||||||
{
|
{
|
||||||
create: selectedLocations.map((location_id) => ({
|
create: toCreate.map((location_id) => ({
|
||||||
location_id,
|
location_id,
|
||||||
})),
|
})),
|
||||||
delete: unselectedLocations,
|
delete: toDelete,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -117,14 +81,10 @@ export const ManageLocationsForm = ({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouteDrawer.Form form={form}>
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<KeyboundForm
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="flex flex-1 flex-col overflow-hidden"
|
|
||||||
>
|
|
||||||
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-4 overflow-auto">
|
<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="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">
|
<div className="grid grid-cols-2 divide-x">
|
||||||
@@ -155,28 +115,56 @@ export const ManageLocationsForm = ({
|
|||||||
<Text size="small" leading="compact">
|
<Text size="small" leading="compact">
|
||||||
{"("}
|
{"("}
|
||||||
{t("general.countOfTotalSelected", {
|
{t("general.countOfTotalSelected", {
|
||||||
count: locationFields.filter((l) => l.selected).length,
|
count: selectedLocationIds.size,
|
||||||
total: locations.length,
|
total: count,
|
||||||
})}
|
})}
|
||||||
{")"}
|
{")"}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{locationFields.map((location, idx) => {
|
|
||||||
return (
|
<LocationSearchInput
|
||||||
<LocationItem
|
onSearchChange={setSearchQuery}
|
||||||
selected={location.selected}
|
placeholder={t("general.search")}
|
||||||
location={location as any}
|
|
||||||
onSelect={() =>
|
|
||||||
updateField(idx, {
|
|
||||||
...location,
|
|
||||||
selected: !location.selected,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
key={location.id}
|
|
||||||
/>
|
/>
|
||||||
)
|
|
||||||
})}
|
<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={selectedLocationIds.has(location.id)}
|
||||||
|
location={location}
|
||||||
|
onSelect={(selected) =>
|
||||||
|
handleLocationSelect(location.id, selected)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
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.Body>
|
||||||
<RouteDrawer.Footer>
|
<RouteDrawer.Footer>
|
||||||
<div className="flex items-center justify-end gap-x-2">
|
<div className="flex items-center justify-end gap-x-2">
|
||||||
@@ -185,12 +173,11 @@ export const ManageLocationsForm = ({
|
|||||||
{t("actions.cancel")}
|
{t("actions.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</RouteDrawer.Close>
|
</RouteDrawer.Close>
|
||||||
<Button type="submit" size="small" isLoading={false}>
|
<Button onClick={handleSubmit} size="small" isLoading={false}>
|
||||||
{t("actions.save")}
|
{t("actions.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</RouteDrawer.Footer>
|
</RouteDrawer.Footer>
|
||||||
</KeyboundForm>
|
</div>
|
||||||
</RouteDrawer.Form>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user