From 7af9e3224cab141bf8c8283032cb508122a0f740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Wed, 24 Sep 2025 20:00:45 +0200 Subject: [PATCH] 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 --- .changeset/spicy-timers-breathe.md | 5 + .../dashboard/src/hooks/api/inventory.tsx | 3 + .../src/i18n/translations/$schema.json | 4 + .../dashboard/src/i18n/translations/en.json | 1 + .../use-location-list-table-columns.tsx | 7 + .../components/location-item.tsx | 4 +- .../components/location-search-input.tsx | 34 +++ .../components/manage-locations-form.tsx | 271 +++++++++--------- .../workflows/delete-inventory-levels.ts | 16 +- 9 files changed, 193 insertions(+), 152 deletions(-) create mode 100644 .changeset/spicy-timers-breathe.md create mode 100644 packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/location-search-input.tsx diff --git a/.changeset/spicy-timers-breathe.md b/.changeset/spicy-timers-breathe.md new file mode 100644 index 0000000000..3da2d3a1eb --- /dev/null +++ b/.changeset/spicy-timers-breathe.md @@ -0,0 +1,5 @@ +--- +"@medusajs/dashboard": patch +--- + +fix(dashboard): improve inventory level location management diff --git a/packages/admin/dashboard/src/hooks/api/inventory.tsx b/packages/admin/dashboard/src/hooks/api/inventory.tsx index ae2dc01522..3f2c2fe699 100644 --- a/packages/admin/dashboard/src/hooks/api/inventory.tsx +++ b/packages/admin/dashboard/src/hooks/api/inventory.tsx @@ -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, diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 1db0f2d58a..bab6e44430 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -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", diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index 3be87b4f4a..8f4e7bc1a3 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -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", diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-columns.tsx b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-columns.tsx index 82db6d8bcb..4540d655c0 100644 --- a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-columns.tsx +++ b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-columns.tsx @@ -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), }) diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/location-item.tsx b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/location-item.tsx index eadd4f1089..efb844d0dd 100644 --- a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/location-item.tsx +++ b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/location-item.tsx @@ -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 = ({ diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/location-search-input.tsx b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/location-search-input.tsx new file mode 100644 index 0000000000..d62acee0f6 --- /dev/null +++ b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/location-search-input.tsx @@ -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 ( + setSearchValue(e.target.value)} + className="w-full" + /> + ) +} diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/manage-locations-form.tsx b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/manage-locations-form.tsx index d332ca98e1..c743eda0b7 100644 --- a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/manage-locations-form.tsx +++ b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/manage-locations/components/manage-locations-form.tsx @@ -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 -) => { - 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>( + existingLocationLevels + ) - const form = useForm>({ - 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 ( - - - -
-
- - {t("fields.title")} - - - {item.title ?? "-"} - -
-
- - {t("fields.sku")} - - - {item.sku} - -
-
-
- - {t("locations.domain")} +
+ +
+
+ + {t("fields.title")} + + + {item.title ?? "-"} -
- - {t("locations.selectLocations")} - - - {"("} - {t("general.countOfTotalSelected", { - count: locationFields.filter((l) => l.selected).length, - total: locations.length, - })} - {")"} - -
- {locationFields.map((location, idx) => { - return ( +
+ + {t("fields.sku")} + + + {item.sku} + +
+
+
+ + {t("locations.domain")} + +
+ + {t("locations.selectLocations")} + + + {"("} + {t("general.countOfTotalSelected", { + count: selectedLocationIds.size, + total: count, + })} + {")"} + +
+
+ + + +
+ + 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) => ( - updateField(idx, { - ...location, - selected: !location.selected, - }) + selected={selectedLocationIds.has(location.id)} + location={location} + onSelect={(selected) => + handleLocationSelect(location.id, selected) } - key={location.id} /> - ) - })} - - -
- - - -
+ + +
+ + -
-
- - + + +
+ +
) } diff --git a/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts b/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts index 1e437396f2..609658adce 100644 --- a/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts +++ b/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts @@ -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(