From ab7ff64c4a7386935fefcfcb24c59748c3422d39 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Wed, 10 Apr 2024 15:17:54 +0200 Subject: [PATCH] Feat(admin-next, core-flows, link-modules, medusa, types): Inventory end to end flows (#7020) * add reservation endpoints * add changeset * initial * add reservations table * add edit-item modal * udpate inventory item attributes * manage locations skeleton * add combi batch endpoint * cleanup * fix manage locations * add adjust inventory * prep for pr * update versions * fix for pr * fix for pr * cleanup * Update packages/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com> * Update packages/core-flows/src/inventory/steps/delete-levels-by-item-and-location.ts Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com> * rm wack import * fix build --------- Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com> --- .../api/__tests__/admin/sales-channels.js | 1 - .../modules/__tests__/inventory/index.spec.ts | 47 ++- .../public/locales/en-US/translation.json | 7 + .../common/action-menu/action-menu.tsx | 5 +- .../dashboard/src/hooks/api/inventory.tsx | 291 ++++++++++++++++++ .../dashboard/src/lib/client/client.ts | 2 + .../dashboard/src/lib/client/inventory.ts | 131 ++++++++ .../src/providers/router-provider/v2.tsx | 89 +++++- .../details-section/details-section.tsx | 13 +- .../dashboard/src/types/api-payloads.ts | 19 ++ .../dashboard/src/types/api-responses.ts | 32 ++ .../adjust-inventory-drawer.tsx | 55 ++++ .../components/adjust-inventory-form.tsx | 152 +++++++++ .../components/adjust-inventory/index.ts | 1 + .../components/edit-item-attributes-form.tsx | 252 +++++++++++++++ .../edit-item-attributes-drawer.tsx | 33 ++ .../edit-inventory-item-attributes/index.ts | 1 + .../components/edit-item-form.tsx | 105 +++++++ .../edit-inventory-item/edit-item-drawer.tsx | 33 ++ .../components/edit-inventory-item/index.ts | 1 + .../attributes-section.tsx | 49 +++ .../inventory-item-general-section.tsx | 80 +++++ .../inventory-item-location-levels.tsx | 28 ++ .../inventory-item-reservations.tsx | 27 ++ .../location-actions.tsx | 60 ++++ .../location-list-table.tsx | 50 +++ .../use-location-list-table-columns.tsx | 97 ++++++ .../use-location-list-table-query.tsx | 44 +++ .../components/location-item.tsx | 42 +++ .../components/manage-locations-form.tsx | 184 +++++++++++ .../components/manage-locations/index.ts | 1 + .../manage-locations-drawer.tsx | 39 +++ .../reservation-actions.tsx | 57 ++++ .../reservation-list-table.tsx | 51 +++ .../use-reservation-list-table-columns.tsx | 107 +++++++ .../use-reservation-list-table-query.tsx | 35 +++ .../inventory/inventory-detail/index.ts | 2 + .../inventory-detail/inventory-detail.tsx | 59 ++++ .../inventory/inventory-detail/loader.ts | 23 ++ .../components/inventory-actions.tsx | 53 ++++ .../components/inventory-list-table.tsx | 61 ++++ .../use-inventory-table-columns.tsx | 96 ++++++ .../use-inventory-table-filters.tsx | 82 +++++ .../components/use-inventory-table-query.tsx | 57 ++++ .../inventory/inventory-list/index.ts | 1 + .../inventory-list/inventory-list.tsx | 11 + .../steps/create-inventory-levels.ts | 7 +- .../delete-levels-by-item-and-location.ts | 62 ++++ .../core-flows/src/inventory/steps/index.ts | 1 + .../steps/validate-inventory-locations.ts | 4 +- .../workflows/bulk-create-delete-levels.ts | 26 ++ .../workflows/create-inventory-levels.ts | 4 +- .../src/inventory/workflows/index.ts | 1 + .../product-variant-inventory-item.ts | 7 +- .../[id]/location-levels/batch/combi/route.ts | 55 ++++ .../[id]/location-levels/route.ts | 4 +- .../admin/inventory-items/middlewares.ts | 8 + .../admin/inventory-items/query-config.ts | 6 +- .../admin/inventory-items/validators.ts | 20 +- packages/medusa/src/api-v2/middlewares.ts | 1 + packages/types/src/http/index.ts | 1 + packages/types/src/http/inventory/index.ts | 2 + .../src/http/inventory/inventory-level.ts | 26 ++ .../types/src/http/inventory/inventory.ts | 32 ++ .../src/inventory/common/inventory-level.ts | 2 - .../inventory/mutations/inventory-level.ts | 2 +- 66 files changed, 2894 insertions(+), 43 deletions(-) create mode 100644 packages/admin-next/dashboard/src/hooks/api/inventory.tsx create mode 100644 packages/admin-next/dashboard/src/lib/client/inventory.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/adjust-inventory-drawer.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/components/adjust-inventory-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/components/edit-item-attributes-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/edit-item-attributes-drawer.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/components/edit-item-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/edit-item-drawer.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-attributes/attributes-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-general-section.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-location-levels.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-reservations.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/location-actions.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/location-list-table.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-columns.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-query.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/components/location-item.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/components/manage-locations-form.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/manage-locations-drawer.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/reservation-actions.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/reservation-list-table.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/use-reservation-list-table-columns.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/use-reservation-list-table-query.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/inventory-detail.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/loader.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/inventory-actions.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/inventory-list-table.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-columns.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-filters.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-query.tsx create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/index.ts create mode 100644 packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/inventory-list.tsx create mode 100644 packages/core-flows/src/inventory/steps/delete-levels-by-item-and-location.ts create mode 100644 packages/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts create mode 100644 packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/batch/combi/route.ts create mode 100644 packages/types/src/http/inventory/index.ts create mode 100644 packages/types/src/http/inventory/inventory-level.ts create mode 100644 packages/types/src/http/inventory/inventory.ts diff --git a/integration-tests/api/__tests__/admin/sales-channels.js b/integration-tests/api/__tests__/admin/sales-channels.js index 1d12439d79..d23ca22243 100644 --- a/integration-tests/api/__tests__/admin/sales-channels.js +++ b/integration-tests/api/__tests__/admin/sales-channels.js @@ -413,7 +413,6 @@ medusaIntegrationTestRunner({ ContainerRegistrationKeys.REMOTE_LINK ) - console.warn("testing") await remoteLink.create([ { [Modules.SALES_CHANNEL]: { diff --git a/integration-tests/modules/__tests__/inventory/index.spec.ts b/integration-tests/modules/__tests__/inventory/index.spec.ts index c7128b68cd..d94e9ae7d0 100644 --- a/integration-tests/modules/__tests__/inventory/index.spec.ts +++ b/integration-tests/modules/__tests__/inventory/index.spec.ts @@ -1,9 +1,9 @@ -import { IInventoryServiceNext, IStockLocationService } from "@medusajs/types" - import { ContainerRegistrationKeys, remoteQueryObjectFromString, } from "@medusajs/utils" +import { IInventoryServiceNext, IStockLocationService } from "@medusajs/types" + import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { createAdminUser } from "../../../helpers/create-admin-user" @@ -314,6 +314,49 @@ medusaIntegrationTestRunner({ }) }) + describe("Bulk create/delete inventory levels", () => { + const locationId = "loc_1" + let inventoryItem + + beforeEach(async () => { + inventoryItem = await service.create({ + sku: "MY_SKU", + }) + + await service.createInventoryLevels([ + { + inventory_item_id: inventoryItem.id, + location_id: locationId, + stocked_quantity: 10, + }, + ]) + }) + + it("should delete an inventory location level and create a new one", async () => { + const result = await api.post( + `/admin/inventory-items/${inventoryItem.id}/location-levels/batch/combi`, + { + creates: [ + { + location_id: "location_2", + }, + ], + deletes: [locationId], + }, + adminHeaders + ) + + expect(result.status).toEqual(200) + + const levelsListResult = await api.get( + `/admin/inventory-items/${inventoryItem.id}/location-levels`, + adminHeaders + ) + expect(levelsListResult.status).toEqual(200) + expect(levelsListResult.data.inventory_levels).toHaveLength(1) + }) + }) + describe("Delete inventory levels", () => { const locationId = "loc_1" let inventoryItem diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index ba02683ec3..219c0b6867 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -30,6 +30,7 @@ "items_one": "{{count}} item", "items_other": "{{count}} items", "countSelected": "{{count}} selected", + "countOfTotalSelected": "{{count}} of {{total}} selected", "plusCount": "+ {{count}}", "plusCountMore": "+ {{count}} more", "areYouSure": "Are you sure?", @@ -265,6 +266,7 @@ }, "inventory": { "header": "Stock & Inventory", + "editItemDetails": "Edit item details", "manageInventoryLabel": "Manage inventory", "manageInventoryHint": "When enabled the inventory level will be regulated when orders and returns are created.", "allowBackordersLabel": "Allow backorders", @@ -311,6 +313,10 @@ "inventory": { "domain": "Inventory", "reserved": "Reserved", + "available": "Available", + "locationLevels": "Location levels", + "associatedVariants": "Associated variants", + "manageLocations": "Manage locations", "deleteWarning": "You are about to delete an inventory item. This action cannot be undone." }, "giftCards": { @@ -943,6 +949,7 @@ "addSalesChannels": "Add sales channels", "detailsHint": "Specify the details of the location.", "noLocationsFound": "No locations found", + "selectLocations": "Select locations that stock the item.", "deleteLocationWarning": "You are about to delete the location {{name}}. This action cannot be undone.", "removeSalesChannelsWarning_one": "You are about to remove {{count}} sales channel from the location.", "removeSalesChannelsWarning_other": "You are about to remove {{count}} sales channels from the location." diff --git a/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx b/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx index 1a621ae89c..32ea7e190a 100644 --- a/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx +++ b/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx @@ -1,7 +1,8 @@ -import { EllipsisHorizontal } from "@medusajs/icons" import { DropdownMenu, IconButton } from "@medusajs/ui" -import { ReactNode } from "react" + +import { EllipsisHorizontal } from "@medusajs/icons" import { Link } from "react-router-dom" +import { ReactNode } from "react" type Action = { icon: ReactNode diff --git a/packages/admin-next/dashboard/src/hooks/api/inventory.tsx b/packages/admin-next/dashboard/src/hooks/api/inventory.tsx new file mode 100644 index 0000000000..3dbf02ea67 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/inventory.tsx @@ -0,0 +1,291 @@ +import { + InventoryItemDeleteRes, + InventoryItemListRes, + InventoryItemLocationLevelsRes, + InventoryItemRes, + ReservationItemDeleteRes, + ReservationItemListRes, + ReservationItemRes, +} from "../../types/api-responses" +import { + InventoryItemLocationBatch, + UpdateInventoryItemReq, + UpdateInventoryLevelReq, +} from "../../types/api-payloads" +import { + QueryKey, + UseMutationOptions, + UseQueryOptions, + useMutation, + useQuery, +} from "@tanstack/react-query" + +import { InventoryNext } from "@medusajs/types" +import { client } from "../../lib/client" +import { queryClient } from "../../lib/medusa" +import { queryKeysFactory } from "../../lib/query-key-factory" + +const INVENTORY_ITEMS_QUERY_KEY = "inventory_items" as const +export const inventoryItemsQueryKeys = queryKeysFactory( + INVENTORY_ITEMS_QUERY_KEY +) + +const INVENTORY_ITEM_LEVELS_QUERY_KEY = "inventory_item_levels" as const +export const inventoryItemLevelsQueryKeys = queryKeysFactory( + INVENTORY_ITEM_LEVELS_QUERY_KEY +) + +const RESERVATION_ITEMS_QUERY_KEY = "reservation_items" as const +export const reservationItemsQueryKeys = queryKeysFactory( + RESERVATION_ITEMS_QUERY_KEY +) + +export const useInventoryItems = ( + query?: Record, + options?: Omit< + UseQueryOptions< + InventoryItemListRes, + Error, + InventoryItemListRes, + QueryKey + >, + "queryKey" | "queryFn" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => client.inventoryItems.list(query), + queryKey: inventoryItemsQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useInventoryItem = ( + id: string, + query?: Record, + options?: Omit< + UseQueryOptions, + "queryKey" | "queryFn" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => client.inventoryItems.retrieve(id, query), + queryKey: inventoryItemsQueryKeys.detail(id), + ...options, + }) + + return { ...data, ...rest } +} + +export const useUpdateInventoryItem = ( + id: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: (payload: InventoryNext.UpdateInventoryItemInput) => + client.inventoryItems.update(id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.detail(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteInventoryItem = ( + id: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.inventoryItems.delete(id), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.detail(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteInventoryItemLevel = ( + inventoryItemId: string, + locationId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => + client.inventoryItems.deleteInventoryItemLevel( + inventoryItemId, + locationId + ), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.detail(inventoryItemId), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useInventoryItemLevels = ( + inventoryItemId: string, + query?: Record, + options?: Omit< + UseQueryOptions< + InventoryItemLocationLevelsRes, + Error, + InventoryItemLocationLevelsRes, + QueryKey + >, + "queryKey" | "queryFn" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => + client.inventoryItems.listLocationLevels(inventoryItemId, query), + queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId), + ...options, + }) + + return { ...data, ...rest } +} + +export const useUpdateInventoryItemLevel = ( + inventoryItemId: string, + locationId: string, + options?: UseMutationOptions< + AdminInventoryLevelResponse, + Error, + UpdateInventoryLevelReq + > +) => { + return useMutation({ + mutationFn: (payload: UpdateInventoryLevelReq) => + client.inventoryItems.updateInventoryLevel( + inventoryItemId, + locationId, + payload + ), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.detail(inventoryItemId), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useBatchInventoryItemLevels = ( + inventoryItemId: string, + options?: UseMutationOptions< + InventoryItemLocationLevelsRes, + Error, + InventoryItemLocationBatch + > +) => { + return useMutation({ + mutationFn: (payload: InventoryItemLocationBatch) => + client.inventoryItems.batchPostLocationLevels(inventoryItemId, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.detail(inventoryItemId), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemLevelsQueryKeys.detail(inventoryItemId), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useReservationItems = ( + query?: Record, + options?: Omit< + UseQueryOptions< + ReservationItemListRes, + Error, + ReservationItemListRes, + QueryKey + >, + "queryKey" | "queryFn" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => client.inventoryItems.listReservationItems(query), + queryKey: reservationItemsQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useUpdateReservationItem = ( + id: string, + payload: InventoryNext.UpdateInventoryItemInput, + options?: UseMutationOptions< + ReservationItemRes, + Error, + UpdateInventoryItemReq + > +) => { + return useMutation({ + mutationFn: () => client.inventoryItems.updateReservationItem(id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.detail(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteReservationItem = ( + id: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.inventoryItems.deleteReservationItem(id), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.lists(), + }) + queryClient.invalidateQueries({ + queryKey: inventoryItemsQueryKeys.detail(id), + }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts index f38dd0dfcc..58fe10bcf7 100644 --- a/packages/admin-next/dashboard/src/lib/client/client.ts +++ b/packages/admin-next/dashboard/src/lib/client/client.ts @@ -6,6 +6,7 @@ import { collections } from "./collections" import { currencies } from "./currencies" import { customerGroups } from "./customer-groups" import { customers } from "./customers" +import { inventoryItems } from "./inventory" import { invites } from "./invites" import { payments } from "./payments" import { priceLists } from "./price-lists" @@ -39,6 +40,7 @@ export const client = { regions: regions, taxes: taxes, invites: invites, + inventoryItems: inventoryItems, products: products, productTypes: productTypes, priceLists: priceLists, diff --git a/packages/admin-next/dashboard/src/lib/client/inventory.ts b/packages/admin-next/dashboard/src/lib/client/inventory.ts new file mode 100644 index 0000000000..7c52470334 --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/client/inventory.ts @@ -0,0 +1,131 @@ +import { + AdminInventoryItemListResponse, + AdminInventoryItemResponse, + AdminInventoryLevelListResponse, + AdminInventoryLevelResponse, +} from "@medusajs/types" +import { + CreateInventoryItemReq, + InventoryItemLocationBatch, + UpdateInventoryItemReq, + UpdateInventoryLevelReq, +} from "../../types/api-payloads" +import { + InventoryItemLevelDeleteRes, + ReservationItemDeleteRes, + ReservationItemListRes, + ReservationItemRes, +} from "../../types/api-responses" +import { deleteRequest, getRequest, postRequest } from "./common" + +async function retrieveInventoryItem(id: string, query?: Record) { + return getRequest( + `/admin/inventory-items/${id}`, + query + ) +} + +async function listInventoryItems(query?: Record) { + return getRequest( + `/admin/inventory-items`, + query + ) +} + +async function createInventoryItem(payload: CreateInventoryItemReq) { + return postRequest( + `/admin/inventory-items`, + payload + ) +} + +async function updateInventoryItem( + id: string, + payload: UpdateInventoryItemReq +) { + return postRequest( + `/admin/inventory-items/${id}`, + payload + ) +} + +async function deleteInventoryItem(id: string) { + return deleteRequest( + `/admin/inventory-items/${id}` + ) +} + +async function listInventoryItemLevels( + id: string, + query?: Record +) { + return getRequest( + `/admin/inventory-items/${id}/location-levels`, + query + ) +} + +async function deleteInventoryItemLevel( + inventoryItemId: string, + locationId: string +) { + return deleteRequest( + `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}` + ) +} + +async function updateInventoryLevel( + inventoryItemId: string, + locationId: string, + payload: UpdateInventoryLevelReq +) { + return postRequest( + `/admin/inventory-items/${inventoryItemId}/location-levels/${locationId}`, + payload + ) +} + +async function listReservationItems(query?: Record) { + return getRequest(`/admin/reservations`, query) +} + +async function deleteReservationItem(reservationId: string) { + return deleteRequest( + `/admin/reservations/${reservationId}` + ) +} + +async function updateReservationItem( + reservationId: string, + payload: UpdateInventoryItemReq +) { + return postRequest( + `/admin/reservatinos/${reservationId}`, + payload + ) +} + +async function batchPostLocationLevels( + inventoryItemId: string, + payload: InventoryItemLocationBatch +) { + return postRequest( + `/admin/inventory-items/${inventoryItemId}/location-levels/batch/combi`, + payload + ) +} + +export const inventoryItems = { + retrieve: retrieveInventoryItem, + list: listInventoryItems, + create: createInventoryItem, + update: updateInventoryItem, + delete: deleteInventoryItem, + listLocationLevels: listInventoryItemLevels, + updateInventoryLevel, + deleteInventoryItemLevel, + listReservationItems, + deleteReservationItem, + updateReservationItem, + batchPostLocationLevels, +} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx index 023b76149b..f2a61d5e9a 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -1,11 +1,3 @@ -import { AdminCustomersRes } from "@medusajs/client-types" -import { Spinner } from "@medusajs/icons" -import { - AdminCollectionsRes, - AdminProductsRes, - AdminPromotionRes, - AdminRegionsRes, -} from "@medusajs/medusa" import { AdminApiKeyResponse, AdminCustomerGroupResponse, @@ -13,14 +5,24 @@ import { SalesChannelDTO, UserDTO, } from "@medusajs/types" +import { + AdminCollectionsRes, + AdminProductsRes, + AdminPromotionRes, + AdminRegionsRes, +} from "@medusajs/medusa" import { Navigate, Outlet, RouteObject, useLocation } from "react-router-dom" + +import { AdminCustomersRes } from "@medusajs/client-types" import { ErrorBoundary } from "../../components/error/error-boundary" +import { InventoryItemRes } from "../../types/api-responses" import { MainLayout } from "../../components/layout-v2/main-layout" -import { SettingsLayout } from "../../components/layout/settings-layout" -import { useMe } from "../../hooks/api/users" import { PriceListRes } from "../../types/api-responses" import { SearchProvider } from "../search-provider" +import { SettingsLayout } from "../../components/layout/settings-layout" import { SidebarProvider } from "../sidebar-provider" +import { Spinner } from "@medusajs/icons" +import { useMe } from "../../hooks/api/users" export const ProtectedRoute = () => { const { user, isLoading } = useMe() @@ -393,6 +395,73 @@ export const v2Routes: RouteObject[] = [ }, ], }, + { + path: "/inventory", + handle: { + crumb: () => "Inventory", + }, + children: [ + { + path: "", + lazy: () => import("../../v2-routes/inventory/inventory-list"), + }, + { + path: ":id", + lazy: () => + import("../../v2-routes/inventory/inventory-detail"), + handle: { + crumb: (data: InventoryItemRes) => + data.inventory_item.title ?? data.inventory_item.sku, + }, + children: [ + { + // TODO: edit item + path: "edit", + lazy: () => + import( + "../../v2-routes/inventory/inventory-detail/components/edit-inventory-item" + ), + }, + { + // TODO: edit item attributes + path: "attributes", + lazy: () => + import( + "../../v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes" + ), + }, + { + // TODO: manage locations + path: "locations", + lazy: () => + import( + "../../v2-routes/inventory/inventory-detail/components/manage-locations" + ), + }, + { + // TODO: adjust item level + path: "locations/:location_id", + lazy: () => + import( + "../../v2-routes/inventory/inventory-detail/components/adjust-inventory" + ), + }, + { + // TODO: create reservation + path: "reservations", + lazy: () => + import("../../v2-routes/customers/customer-edit"), + }, + { + // TODO: edit reservation + path: "reservations/:reservation_id", + lazy: () => + import("../../v2-routes/customers/customer-edit"), + }, + ], + }, + ], + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/discounts/discount-detail/components/details-section/details-section.tsx b/packages/admin-next/dashboard/src/routes/discounts/discount-detail/components/details-section/details-section.tsx index 6963683b50..68c1047299 100644 --- a/packages/admin-next/dashboard/src/routes/discounts/discount-detail/components/details-section/details-section.tsx +++ b/packages/admin-next/dashboard/src/routes/discounts/discount-detail/components/details-section/details-section.tsx @@ -1,12 +1,11 @@ -import { useTranslation } from "react-i18next" - -import { PencilSquare } from "@medusajs/icons" -import type { Discount } from "@medusajs/medusa" import { Container, Copy, Heading, Text } from "@medusajs/ui" import { ActionMenu } from "../../../../../components/common/action-menu" -import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell" +import type { Discount } from "@medusajs/medusa" import { ListSummary } from "../../../../../components/common/list-summary" +import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell" +import { PencilSquare } from "@medusajs/icons" +import { useTranslation } from "react-i18next" export const DetailsSection = ({ discount }: { discount: Discount }) => { const { t } = useTranslation() @@ -54,8 +53,8 @@ export const DetailsSection = ({ discount }: { discount: Discount }) => { {discount.rule.type === "percentage" ? t("discounts.percentageDiscount") : discount.rule.type === "free_shipping" - ? t("discounts.freeShipping") - : t("discounts.fixedDiscount")} + ? t("discounts.freeShipping") + : t("discounts.fixedDiscount")} diff --git a/packages/admin-next/dashboard/src/types/api-payloads.ts b/packages/admin-next/dashboard/src/types/api-payloads.ts index 95c9a85b0f..6f160a7844 100644 --- a/packages/admin-next/dashboard/src/types/api-payloads.ts +++ b/packages/admin-next/dashboard/src/types/api-payloads.ts @@ -14,6 +14,7 @@ import { CreateRegionDTO, CreateSalesChannelDTO, CreateStockLocationInput, + InventoryNext, UpdateApiKeyDTO, UpdateCampaignDTO, UpdateCustomerDTO, @@ -88,3 +89,21 @@ export type BatchUpdatePromotionRulesReq = { rules: UpdatePromotionRuleDTO[] } // Campaign export type CreateCampaignReq = CreateCampaignDTO export type UpdateCampaignReq = UpdateCampaignDTO + +// Inventory Items +export type CreateInventoryItemReq = InventoryNext.CreateInventoryItemInput +export type UpdateInventoryItemReq = Omit< + InventoryNext.UpdateInventoryItemInput, + "id" +> + +// Inventory Item Levels +export type InventoryItemLocationBatch = { + creates: { location_id: string; stocked_quantity?: number }[] + deletes: string[] +} + +export type UpdateInventoryLevelReq = { + reserved_quantity?: number + stocked_quantity?: number +} diff --git a/packages/admin-next/dashboard/src/types/api-responses.ts b/packages/admin-next/dashboard/src/types/api-responses.ts index 6dd21f4036..6bdfaa7ddb 100644 --- a/packages/admin-next/dashboard/src/types/api-responses.ts +++ b/packages/admin-next/dashboard/src/types/api-responses.ts @@ -6,6 +6,7 @@ import { CampaignDTO, CurrencyDTO, CustomerGroupDTO, + InventoryNext, InviteDTO, PaymentProviderDTO, PriceListDTO, @@ -22,6 +23,7 @@ import { StoreDTO, UserDTO, } from "@medusajs/types" + import { ProductTagDTO } from "@medusajs/types/dist/product" import { WorkflowExecutionDTO } from "../v2-routes/workflow-executions/types" @@ -140,6 +142,36 @@ export type ProductCollectionListRes = { } & ListRes export type ProductCollectionDeleteRes = DeleteRes +// Inventory Items +export type InventoryItemRes = { + inventory_item: InventoryNext.InventoryItemDTO & { + stocked_quantity: number + reserved_quantity: number + location_levels?: InventoryNext.InventoryLevelDTO[] + } +} + +export type InventoryItemListRes = { + inventory_items: InventoryNext.InventoryItemDTO[] +} & ListRes +export type InventoryItemDeleteRes = DeleteRes + +export type InventoryItemLocationLevelsRes = { + inventory_levels: InventoryNext.InventoryLevelDTO[] +} & ListRes + +export type InventoryItemLevelDeleteRes = DeleteRes + +// Reservations +export type ReservationItemDeleteRes = DeleteRes + +export type ReservationItemListRes = { + reservations: InventoryNext.ReservationItemDTO[] +} & ListRes + +export type ReservationItemRes = { + reservation: InventoryNext.ReservationItemDTO +} // Price Lists export type PriceListRes = { price_list: PriceListDTO } export type PriceListListRes = { price_lists: PriceListDTO[] } & ListRes diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/adjust-inventory-drawer.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/adjust-inventory-drawer.tsx new file mode 100644 index 0000000000..1580fe4b5d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/adjust-inventory-drawer.tsx @@ -0,0 +1,55 @@ +import { AdjustInventoryForm } from "./components/adjust-inventory-form" +import { Heading } from "@medusajs/ui" +import { InventoryNext } from "@medusajs/types" +import { RouteDrawer } from "../../../../../components/route-modal" +import { useInventoryItem } from "../../../../../hooks/api/inventory" +import { useParams } from "react-router-dom" +import { useStockLocation } from "../../../../../hooks/api/stock-locations" +import { useTranslation } from "react-i18next" + +export const AdjustInventoryDrawer = () => { + const { id, location_id } = useParams() + const { t } = useTranslation() + + const { + inventory_item: inventoryItem, + isLoading, + isError, + error, + } = useInventoryItem(id!) + + const inventoryLevel = inventoryItem?.location_levels!.find( + (level: InventoryNext.InventoryLevelDTO) => + level.location_id === location_id + ) + + const { stock_location, isLoading: isLoadingLocation } = useStockLocation( + location_id! + ) + + const ready = + !isLoading && + inventoryItem && + inventoryLevel && + !isLoadingLocation && + stock_location + + if (isError) { + throw error + } + + return ( + + + {t("inventory.manageLocations")} + + {ready && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/components/adjust-inventory-form.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/components/adjust-inventory-form.tsx new file mode 100644 index 0000000000..93be5b78ea --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/components/adjust-inventory-form.tsx @@ -0,0 +1,152 @@ +import * as zod from "zod" + +import { Button, Input, Text } from "@medusajs/ui" +import { InventoryLevelDTO, StockLocationDTO } from "@medusajs/types" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../../components/route-modal" + +import { Form } from "../../../../../../components/common/form" +import { InventoryItemRes } from "../../../../../../types/api-responses" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { useUpdateInventoryItemLevel } from "../../../../../../hooks/api/inventory" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" + +type AdjustInventoryFormProps = { + item: InventoryItemRes["inventory_item"] + level: InventoryLevelDTO + location: StockLocationDTO +} + +const AttributeGridRow = ({ + title, + value, +}: { + title: string + value: string | number +}) => { + return ( +
+ + {title} + + + {value} + +
+ ) +} + +export const AdjustInventoryForm = ({ + item, + level, + location, +}: AdjustInventoryFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const AdjustInventorySchema = z.object({ + stocked_quantity: z.number().min(level.reserved_quantity), + }) + + const form = useForm>({ + defaultValues: { + stocked_quantity: level.stocked_quantity, + }, + resolver: zodResolver(AdjustInventorySchema), + }) + + const stockedQuantityUpdate = form.watch("stocked_quantity") + + const { mutateAsync } = useUpdateInventoryItemLevel( + item.id, + level.location_id + ) + + const handleSubmit = form.handleSubmit(async (value) => { + if (value.stocked_quantity === level.stocked_quantity) { + return handleSuccess() + } + + await mutateAsync({ + stocked_quantity: value.stocked_quantity, + }) + + return handleSuccess() + }) + + return ( + +
+ +
+ + + + + +
+ { + return ( + + {t("fields.inStock")} + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(parseFloat(value)) + } + }} + {...field} + /> + + + + ) + }} + /> +
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/index.ts b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/index.ts new file mode 100644 index 0000000000..63d0eec75b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/adjust-inventory/index.ts @@ -0,0 +1 @@ +export { AdjustInventoryDrawer as Component } from "./adjust-inventory-drawer" diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/components/edit-item-attributes-form.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/components/edit-item-attributes-form.tsx new file mode 100644 index 0000000000..023ad3313a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/components/edit-item-attributes-form.tsx @@ -0,0 +1,252 @@ +import * as zod from "zod" + +import { Button, Input } from "@medusajs/ui" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../../components/route-modal" + +import { CountrySelect } from "../../../../../../components/common/country-select" +import { Form } from "../../../../../../components/common/form" +import { InventoryNext } from "@medusajs/types" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" + +type EditInventoryItemAttributeFormProps = { + item: InventoryNext.InventoryItemDTO +} + +const EditInventoryItemAttributesSchema = z.object({ + height: z.number().positive().optional(), + width: z.number().positive().optional(), + length: z.number().positive().optional(), + weight: z.number().positive().optional(), + mid_code: z.string().optional(), + hs_code: z.string().optional(), + origin_country: z.string().optional(), +}) + +const getDefaultValues = (item: InventoryNext.InventoryItemDTO) => { + return { + height: item.height ?? undefined, + width: item.width ?? undefined, + length: item.length ?? undefined, + weight: item.weight ?? undefined, + mid_code: item.mid_code ?? undefined, + hs_code: item.hs_code ?? undefined, + origin_country: item.origin_country ?? undefined, + } +} + +export const EditInventoryItemAttributesForm = ({ + item, +}: EditInventoryItemAttributeFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: getDefaultValues(item), + resolver: zodResolver(EditInventoryItemAttributesSchema), + }) + + const { mutateAsync } = useUpdateInventoryItem(item.id) + + const handleSubmit = form.handleSubmit(async (values) => { + mutateAsync(values, { + onSuccess: () => { + handleSuccess() + }, + }) + }) + + return ( + +
+ + { + return ( + + {t("fields.height")} + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(parseFloat(value)) + } + }} + {...field} + /> + + + + ) + }} + /> + { + return ( + + {t("fields.width")} + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(parseFloat(value)) + } + }} + {...field} + /> + + + + ) + }} + /> + { + return ( + + {t("fields.length")} + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(parseFloat(value)) + } + }} + {...field} + /> + + + + ) + }} + /> + { + return ( + + {t("fields.weight")} + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(parseFloat(value)) + } + }} + {...field} + /> + + + + ) + }} + /> + { + return ( + + {t("fields.midCode")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.hsCode")} + + + + + + ) + }} + /> + + { + return ( + + + {t("fields.countryOfOrigin")} + + + + + + + ) + }} + /> + + +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/edit-item-attributes-drawer.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/edit-item-attributes-drawer.tsx new file mode 100644 index 0000000000..82c7172488 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/edit-item-attributes-drawer.tsx @@ -0,0 +1,33 @@ +import { EditInventoryItemAttributesForm } from "./components/edit-item-attributes-form" +import { Heading } from "@medusajs/ui" +import { RouteDrawer } from "../../../../../components/route-modal" +import { useInventoryItem } from "../../../../../hooks/api/inventory" +import { useParams } from "react-router-dom" +import { useTranslation } from "react-i18next" + +export const InventoryItemAttributesEdit = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { + inventory_item: inventoryItem, + isLoading, + isError, + error, + } = useInventoryItem(id!) + + const ready = !isLoading && inventoryItem + + if (isError) { + throw error + } + + return ( + + + {t("products.editAttributes")} + + {ready && } + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/index.ts b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/index.ts new file mode 100644 index 0000000000..75cf07cbd3 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item-attributes/index.ts @@ -0,0 +1 @@ +export { InventoryItemAttributesEdit as Component } from "./edit-item-attributes-drawer" diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/components/edit-item-form.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/components/edit-item-form.tsx new file mode 100644 index 0000000000..f2f98206ae --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/components/edit-item-form.tsx @@ -0,0 +1,105 @@ +import * as zod from "zod" + +import { Button, Input } from "@medusajs/ui" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../../components/route-modal" + +import { Form } from "../../../../../../components/common/form" +import { InventoryNext } from "@medusajs/types" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import { useUpdateInventoryItem } from "../../../../../../hooks/api/inventory" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" + +type EditInventoryItemFormProps = { + item: InventoryNext.InventoryItemDTO +} + +const EditInventoryItemSchema = z.object({ + title: z.string().optional(), + sku: z.string().min(1), +}) + +const getDefaultValues = (item: InventoryNext.InventoryItemDTO) => { + return { + title: item.title ?? undefined, + sku: item.sku ?? undefined, + } +} + +export const EditInventoryItemForm = ({ item }: EditInventoryItemFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: getDefaultValues(item), + resolver: zodResolver(EditInventoryItemSchema), + }) + + const { mutateAsync } = useUpdateInventoryItem(item.id) + + const handleSubmit = form.handleSubmit(async (values) => { + mutateAsync(values as any, { + onSuccess: () => { + handleSuccess() + }, + }) + }) + + return ( + +
+ + { + return ( + + {t("fields.title")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.sku")} + + + + + + ) + }} + /> + + +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/edit-item-drawer.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/edit-item-drawer.tsx new file mode 100644 index 0000000000..b966ec1024 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/edit-item-drawer.tsx @@ -0,0 +1,33 @@ +import { EditInventoryItemForm } from "./components/edit-item-form" +import { Heading } from "@medusajs/ui" +import { RouteDrawer } from "../../../../../components/route-modal" +import { useInventoryItem } from "../../../../../hooks/api/inventory" +import { useParams } from "react-router-dom" +import { useTranslation } from "react-i18next" + +export const InventoryItemEdit = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { + inventory_item: inventoryItem, + isLoading, + isError, + error, + } = useInventoryItem(id!) + + const ready = !isLoading && inventoryItem + + if (isError) { + throw error + } + + return ( + + + {t("inventory.editItemDetails")} + + {ready && } + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/index.ts b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/index.ts new file mode 100644 index 0000000000..b278dc1e4e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/edit-inventory-item/index.ts @@ -0,0 +1 @@ +export { InventoryItemEdit as Component } from "./edit-item-drawer" diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-attributes/attributes-section.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-attributes/attributes-section.tsx new file mode 100644 index 0000000000..5a47844f4c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-attributes/attributes-section.tsx @@ -0,0 +1,49 @@ +import { Container, Heading } from "@medusajs/ui" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { InventoryNext } from "@medusajs/types" +import { PencilSquare } from "@medusajs/icons" +import { SectionRow } from "../../../../../components/common/section" +import { getFormattedCountry } from "../../../../../lib/addresses" +import { useTranslation } from "react-i18next" + +type InventoryItemAttributeSectionProps = { + inventoryItem: InventoryNext.InventoryItemDTO +} + +export const InventoryItemAttributeSection = ({ + inventoryItem, +}: InventoryItemAttributeSectionProps) => { + const { t } = useTranslation() + + return ( + +
+ {t("products.attributes")} + , + }, + ], + }, + ]} + /> +
+ + + + + + + +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-general-section.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-general-section.tsx new file mode 100644 index 0000000000..d757174d90 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-general-section.tsx @@ -0,0 +1,80 @@ +import { Container, Heading, Text } from "@medusajs/ui" +import { InventoryNext, ProductVariantDTO } from "@medusajs/types" + +import { ActionMenu } from "../../../../components/common/action-menu" +import { InventoryItemRes } from "../../../../types/api-responses" +import { PencilSquare } from "@medusajs/icons" +import { SectionRow } from "../../../../components/common/section" +import { useTranslation } from "react-i18next" + +type InventoryItemGeneralSectionProps = { + inventoryItem: InventoryItemRes["inventory_item"] & { + variant: ProductVariantDTO | ProductVariantDTO[] + } +} +export const InventoryItemGeneralSection = ({ + inventoryItem, +}: InventoryItemGeneralSectionProps) => { + const { t } = useTranslation() + + const variantArray = inventoryItem.variant + ? Array.isArray(inventoryItem.variant) + ? inventoryItem.variant + : [inventoryItem.variant] + : [] + + const variantTitles = variantArray.map((variant) => variant.title) + + return ( + +
+ {inventoryItem.title ?? inventoryItem.sku} + , + label: t("actions.edit"), + to: "edit", + }, + ], + }, + ]} + /> +
+ + + + + + +
+ ) +} + +const getQuantityFormat = (quantity: number, locations: number) => { + return `${quantity ?? "-"} + ${quantity ? `across ${locations} locations` : ""}` +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-location-levels.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-location-levels.tsx new file mode 100644 index 0000000000..f58e5e9b65 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-location-levels.tsx @@ -0,0 +1,28 @@ +import { Button, Container, Heading } from "@medusajs/ui" + +import { InventoryItemRes } from "../../../../types/api-responses" +import { ItemLocationListTable } from "./location-levels-table/location-list-table" +import { Link } from "react-router-dom" +import { ReservationItemTable } from "./reservations-table/reservation-list-table" +import { useTranslation } from "react-i18next" + +type InventoryItemLocationLevelsSectionProps = { + inventoryItem: InventoryItemRes["inventory_item"] +} +export const InventoryItemLocationLevelsSection = ({ + inventoryItem, +}: InventoryItemLocationLevelsSectionProps) => { + const { t } = useTranslation() + + return ( + +
+ {t("inventory.locationLevels")} + +
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-reservations.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-reservations.tsx new file mode 100644 index 0000000000..6473955f64 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/inventory-item-reservations.tsx @@ -0,0 +1,27 @@ +import { Button, Container, Heading } from "@medusajs/ui" + +import { InventoryItemRes } from "../../../../types/api-responses" +import { Link } from "react-router-dom" +import { ReservationItemTable } from "./reservations-table/reservation-list-table" +import { useTranslation } from "react-i18next" + +type InventoryItemLocationLevelsSectionProps = { + inventoryItem: InventoryItemRes["inventory_item"] +} +export const InventoryItemReservationsSection = ({ + inventoryItem, +}: InventoryItemLocationLevelsSectionProps) => { + const { t } = useTranslation() + + return ( + +
+ {t("reservations.domain")} + +
+ +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/location-actions.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/location-actions.tsx new file mode 100644 index 0000000000..a2dc301949 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/location-actions.tsx @@ -0,0 +1,60 @@ +import { PencilSquare, Trash } from "@medusajs/icons" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { InventoryNext } from "@medusajs/types" +import { useDeleteInventoryItemLevel } from "../../../../../hooks/api/inventory" +import { usePrompt } from "@medusajs/ui" +import { useTranslation } from "react-i18next" + +export const LocationActions = ({ + level, +}: { + level: InventoryNext.InventoryLevelDTO +}) => { + const { t } = useTranslation() + const prompt = usePrompt() + const { mutateAsync } = useDeleteInventoryItemLevel( + level.inventory_item_id, + level.location_id + ) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("inventory.deleteWarning"), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync() + } + + return ( + , + label: t("actions.edit"), + to: `locations/${level.location_id}`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.delete"), + onClick: handleDelete, + }, + ], + }, + ]} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/location-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/location-list-table.tsx new file mode 100644 index 0000000000..234d4af19f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/location-list-table.tsx @@ -0,0 +1,50 @@ +import { DataTable } from "../../../../../components/table/data-table" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { useInventoryItemLevels } from "../../../../../hooks/api/inventory" +import { useLocationLevelTableQuery } from "./use-location-list-table-query" +import { useLocationListTableColumns } from "./use-location-list-table-columns" + +const PAGE_SIZE = 20 + +export const ItemLocationListTable = ({ + inventory_item_id, +}: { + inventory_item_id: string +}) => { + const { searchParams, raw } = useLocationLevelTableQuery({ + pageSize: PAGE_SIZE, + }) + + const { inventory_levels, count, isLoading, isError, error } = + useInventoryItemLevels(inventory_item_id, { + ...searchParams, + fields: "*stock_locations", + }) + + const columns = useLocationListTableColumns() + + const { table } = useDataTable({ + data: inventory_levels ?? [], + columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + }) + + if (isError) { + throw error + } + + return ( + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-columns.tsx new file mode 100644 index 0000000000..f0034901f8 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-columns.tsx @@ -0,0 +1,97 @@ +import { InventoryNext, StockLocationDTO } from "@medusajs/types" + +import { LocationActions } from "./location-actions" +import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +/** + * Adds missing properties to the InventoryLevelDTO type. + */ +interface ExtendedLocationLevel extends InventoryNext.InventoryLevelDTO { + stock_locations: StockLocationDTO[] + reserved_quantity: number + stocked_quantity: number + available_quantity: number +} + +const columnHelper = createColumnHelper() + +export const useLocationListTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("stock_locations.0.name", { + header: t("fields.location"), + cell: ({ getValue }) => { + const locationName = getValue() + + if (!locationName) { + return + } + + return ( +
+ {locationName.toString()} +
+ ) + }, + }), + columnHelper.accessor("reserved_quantity", { + header: t("inventory.reserved"), + cell: ({ getValue }) => { + const quantity = getValue() + + if (Number.isNaN(quantity)) { + return + } + + return ( +
+ {quantity} +
+ ) + }, + }), + columnHelper.accessor("stocked_quantity", { + header: t("fields.inStock"), + cell: ({ getValue }) => { + const stockedQuantity = getValue() + + if (Number.isNaN(stockedQuantity)) { + return + } + + return ( +
+ {stockedQuantity} +
+ ) + }, + }), + columnHelper.accessor("available_quantity", { + header: t("inventory.available"), + cell: ({ getValue }) => { + const availableQuantity = getValue() + + if (Number.isNaN(availableQuantity)) { + return + } + + return ( +
+ {availableQuantity} +
+ ) + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => , + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-query.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-query.tsx new file mode 100644 index 0000000000..19f74696ae --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/location-levels-table/use-location-list-table-query.tsx @@ -0,0 +1,44 @@ +import { useQueryParams } from "../../../../../hooks/use-query-params" + +export const useLocationLevelTableQuery = ({ + pageSize = 20, + prefix, +}: { + pageSize?: number + prefix?: string +}) => { + const raw = useQueryParams( + [ + "id", + "location_id", + "stocked_quantity", + "reserved_quantity", + "incoming_quantity", + "available_quantity", + "*stock_locations", + ], + prefix + ) + + const { reserved_quantity, stocked_quantity, available_quantity, ...params } = + raw + + const searchParams = { + limit: pageSize, + reserved_quantity: reserved_quantity + ? JSON.parse(reserved_quantity) + : undefined, + stocked_quantity: stocked_quantity + ? JSON.parse(stocked_quantity) + : undefined, + available_quantity: available_quantity + ? JSON.parse(available_quantity) + : undefined, + ...params, + } + + return { + searchParams, + raw, + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/components/location-item.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/components/location-item.tsx new file mode 100644 index 0000000000..60bd656232 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/components/location-item.tsx @@ -0,0 +1,42 @@ +import { Checkbox, Text, clx } from "@medusajs/ui" + +import { StockLocationDTO } from "@medusajs/types" + +type LocationItemProps = { + selected: boolean + onSelect: (selected: boolean) => void + location: StockLocationDTO +} + +export const LocationItem = ({ + selected, + onSelect, + location, +}: LocationItemProps) => { + return ( +
onSelect(!selected)} + > +
+ +
+
+ + {location.name} + + + {[ + location.address?.address_1, + location.address?.city, + location.address?.country_code, + ] + .filter((el) => !!el) + .join(", ")} + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/components/manage-locations-form.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/components/manage-locations-form.tsx new file mode 100644 index 0000000000..6822a937c7 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/components/manage-locations-form.tsx @@ -0,0 +1,184 @@ +import * as zod from "zod" + +import { Button, Table, Text } from "@medusajs/ui" +import { + RouteDrawer, + useRouteModal, +} from "../../../../../../components/route-modal" +import { + useBatchInventoryItemLevels, + useUpdateInventoryItem, +} from "../../../../../../hooks/api/inventory" +import { useFieldArray, useForm } from "react-hook-form" + +import { InventoryItemRes } from "../../../../../../types/api-responses" +import { LocationItem } from "./location-item" +import { StockLocationDTO } from "@medusajs/types" +import { useTranslation } from "react-i18next" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" + +type EditInventoryItemAttributeFormProps = { + item: InventoryItemRes["inventory_item"] + locations: StockLocationDTO[] +} + +const EditInventoryItemAttributesSchema = z.object({ + locations: z.array( + z.object({ + id: z.string(), + location_id: z.string(), + selected: z.boolean(), + }) + ), +}) + +const getDefaultValues = ( + allLocations: StockLocationDTO[], + existinLevels: Set +) => { + return { + locations: allLocations.map((location) => ({ + ...location, + location_id: location.id, + selected: existinLevels.has(location.id), + })), + } +} + +export const ManageLocationsForm = ({ + item, + locations, +}: EditInventoryItemAttributeFormProps) => { + const existingLocationLevels = new Set( + item.location_levels?.map((l) => l.location_id) ?? [] + ) + + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: getDefaultValues(locations, existingLocationLevels), + resolver: zodResolver(EditInventoryItemAttributesSchema), + }) + + const { fields: locationFields, update: updateField } = useFieldArray({ + control: form.control, + name: "locations", + }) + + const { mutateAsync } = useBatchInventoryItemLevels(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[]] + ) + + if (selectedLocations.length === 0 && unselectedLocations.length === 0) { + return handleSuccess() + } + + await mutateAsync({ + creates: selectedLocations.map((location_id) => ({ + location_id, + })), + deletes: unselectedLocations, + }) + + return handleSuccess() + }) + + return ( + +
+ +
+
+ + {t("fields.title")} + + + {item.title ?? "-"} + +
+
+ + {t("fields.sku")} + + + {item.sku} + +
+
+
+ + {t("locations.domain")} + +
+ + {t("locations.selectLocations")} + + + {"("} + {t("general.countOfTotalSelected", { + count: locationFields.filter((l) => l.selected).length, + total: locations.length, + })} + {")"} + +
+
+ {locationFields.map((location, idx) => { + return ( + + updateField(idx, { + ...location, + selected: !location.selected, + }) + } + key={location.id} + /> + ) + })} +
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/index.ts b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/index.ts new file mode 100644 index 0000000000..e8b6358c34 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/index.ts @@ -0,0 +1 @@ +export { ManageLocationsDrawer as Component } from "./manage-locations-drawer" diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/manage-locations-drawer.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/manage-locations-drawer.tsx new file mode 100644 index 0000000000..1c372d334a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/manage-locations/manage-locations-drawer.tsx @@ -0,0 +1,39 @@ +import { Heading } from "@medusajs/ui" +import { ManageLocationsForm } from "./components/manage-locations-form" +import { RouteDrawer } from "../../../../../components/route-modal" +import { useInventoryItem } from "../../../../../hooks/api/inventory" +import { useParams } from "react-router-dom" +import { useStockLocations } from "../../../../../hooks/api/stock-locations" +import { useTranslation } from "react-i18next" + +export const ManageLocationsDrawer = () => { + const { id } = useParams() + const { t } = useTranslation() + + const { + inventory_item: inventoryItem, + isLoading, + isError, + error, + } = useInventoryItem(id!) + + const { stock_locations, isLoading: loadingLocations } = useStockLocations() + + const ready = + !isLoading && !loadingLocations && inventoryItem && stock_locations + + if (isError) { + throw error + } + + return ( + + + {t("inventory.manageLocations")} + + {ready && ( + + )} + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/reservation-actions.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/reservation-actions.tsx new file mode 100644 index 0000000000..05d945ff17 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/reservation-actions.tsx @@ -0,0 +1,57 @@ +import { PencilSquare, Trash } from "@medusajs/icons" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { InventoryNext } from "@medusajs/types" +import { useDeleteReservationItem } from "../../../../../hooks/api/inventory" +import { usePrompt } from "@medusajs/ui" +import { useTranslation } from "react-i18next" + +export const ReservationActions = ({ + reservation, +}: { + reservation: InventoryNext.ReservationItemDTO +}) => { + const { t } = useTranslation() + const prompt = usePrompt() + const { mutateAsync } = useDeleteReservationItem(reservation.id) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("inventory.deleteWarning"), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync() + } + + return ( + , + label: t("actions.edit"), + to: `/reservation/${reservation.id}/edit`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.delete"), + onClick: handleDelete, + }, + ], + }, + ]} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/reservation-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/reservation-list-table.tsx new file mode 100644 index 0000000000..358e55e68b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/reservation-list-table.tsx @@ -0,0 +1,51 @@ +import { DataTable } from "../../../../../components/table/data-table" +import { InventoryNext } from "@medusajs/types" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { useInventoryTableColumns } from "./use-reservation-list-table-columns" +import { useInventoryTableQuery } from "./use-reservation-list-table-query" +import { useReservationItems } from "../../../../../hooks/api/inventory" + +const PAGE_SIZE = 20 + +export const ReservationItemTable = ({ + inventoryItem, +}: { + inventoryItem: InventoryNext.InventoryItemDTO +}) => { + const { searchParams, raw } = useInventoryTableQuery({ + pageSize: PAGE_SIZE, + }) + + const { reservations, count, isLoading, isError, error } = + useReservationItems({ + ...searchParams, + inventory_item_id: [inventoryItem.id], + }) + + const columns = useInventoryTableColumns({ sku: inventoryItem.sku! }) + + const { table } = useDataTable({ + data: reservations ?? [], + columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + }) + + if (isError) { + throw error + } + + return ( + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/use-reservation-list-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/use-reservation-list-table-columns.tsx new file mode 100644 index 0000000000..5222adc999 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/use-reservation-list-table-columns.tsx @@ -0,0 +1,107 @@ +import { InventoryNext, StockLocationDTO } from "@medusajs/types" + +import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell" +import { ReservationActions } from "./reservation-actions" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +/** + * Adds missing properties to the InventoryItemDTO type. + */ +interface ExtendedInventoryItem extends InventoryNext.ReservationItemDTO { + line_item: { order_id: string } + location: StockLocationDTO +} + +const columnHelper = createColumnHelper() + +export const useInventoryTableColumns = ({ sku }: { sku: string }) => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + header: t("fields.sku"), + cell: () => { + return ( +
+ {sku} +
+ ) + }, + }), + columnHelper.accessor("line_item.order_id", { + header: t("inventory.reserved"), + cell: ({ getValue }) => { + const quantity = getValue() + + if (Number.isNaN(quantity)) { + return + } + + return ( +
+ {quantity} +
+ ) + }, + }), + columnHelper.accessor("description", { + header: t("fields.description"), + cell: ({ getValue }) => { + const description = getValue() + + if (!description) { + return + } + + return ( +
+ {description} +
+ ) + }, + }), + columnHelper.accessor("location.name", { + header: t("inventory.location"), + cell: ({ getValue }) => { + const location = getValue() + + if (!location) { + return + } + + return ( +
+ {location} +
+ ) + }, + }), + columnHelper.accessor("created_at", { + header: t("fields.createdAt"), + cell: ({ getValue }) => { + const createdAt = getValue() + + if (!createdAt) { + return + } + + return ( +
+ + {createdAt instanceof Date ? createdAt.toString() : createdAt} + +
+ ) + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => , + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/use-reservation-list-table-query.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/use-reservation-list-table-query.tsx new file mode 100644 index 0000000000..2866bde16b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/components/reservations-table/use-reservation-list-table-query.tsx @@ -0,0 +1,35 @@ +import { useQueryParams } from "../../../../../hooks/use-query-params" + +export const useInventoryTableQuery = ({ + pageSize = 20, + prefix, +}: { + pageSize?: number + prefix?: string +}) => { + const raw = useQueryParams( + [ + "id", + "location_id", + "inventory_item_id", + "quantity", + "line_item_id", + "description", + "created_by", + ], + prefix + ) + + const { quantity, ...params } = raw + + const searchParams = { + limit: pageSize, + quantity: quantity ? JSON.parse(quantity) : undefined, + ...params, + } + + return { + searchParams, + raw, + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/index.ts new file mode 100644 index 0000000000..7360293637 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/index.ts @@ -0,0 +1,2 @@ +export { InventoryDeatil as Component } from "./inventory-detail" +export { inventoryItemLoader as loader } from "./loader" diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/inventory-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/inventory-detail.tsx new file mode 100644 index 0000000000..1e0e75c397 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/inventory-detail.tsx @@ -0,0 +1,59 @@ +import { Outlet, json, useLoaderData, useParams } from "react-router-dom" + +import { InventoryItemAttributeSection } from "./components/inventory-item-attributes/attributes-section" +import { InventoryItemGeneralSection } from "./components/inventory-item-general-section" +import { InventoryItemLocationLevelsSection } from "./components/inventory-item-location-levels" +import { InventoryItemReservationsSection } from "./components/inventory-item-reservations" +import { JsonViewSection } from "../../../components/common/json-view-section" +import { inventoryItemLoader } from "./loader" +import { useInventoryItem } from "../../../hooks/api/inventory" + +export const InventoryDeatil = () => { + const { id } = useParams() + + const initialData = useLoaderData() as Awaited< + ReturnType + > + + const { inventory_item, isLoading, isError, error } = useInventoryItem( + id!, + {}, + { + initialData, + } + ) + + if (isLoading) { + return
Loading...
+ } + + if (isError || !inventory_item) { + if (error) { + throw error + } + + throw json("An unknown error occurred", 500) + } + + return ( +
+
+
+ + + +
+ +
+ +
+
+ +
+ +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/loader.ts b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/loader.ts new file mode 100644 index 0000000000..2f853019be --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-detail/loader.ts @@ -0,0 +1,23 @@ +import { InventoryItemRes } from "../../../types/api-responses" +import { LoaderFunctionArgs } from "react-router-dom" +import { client } from "../../../lib/client" +import { inventoryItemsQueryKeys } from "../../../hooks/api/inventory" +import { queryClient } from "../../../lib/medusa" + +const inventoryDetailQuery = (id: string) => ({ + queryKey: inventoryItemsQueryKeys.detail(id), + queryFn: async () => + client.inventoryItems.retrieve(id, { + fields: "*variants", + }), +}) + +export const inventoryItemLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = inventoryDetailQuery(id!) + + return ( + queryClient.getQueryData(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/inventory-actions.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/inventory-actions.tsx new file mode 100644 index 0000000000..afee34bcb5 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/inventory-actions.tsx @@ -0,0 +1,53 @@ +import { PencilSquare, Trash } from "@medusajs/icons" + +import { ActionMenu } from "../../../../components/common/action-menu" +import { InventoryItemDTO } from "@medusajs/types" +import { useDeleteInventoryItem } from "../../../../hooks/api/inventory" +import { usePrompt } from "@medusajs/ui" +import { useTranslation } from "react-i18next" + +export const InventoryActions = ({ item }: { item: InventoryItemDTO }) => { + const { t } = useTranslation() + const prompt = usePrompt() + const { mutateAsync } = useDeleteInventoryItem(item.id) + + const handleDelete = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("inventory.deleteWarning"), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!res) { + return + } + + await mutateAsync() + } + + return ( + , + label: t("actions.edit"), + to: `${item.id}/edit`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.delete"), + onClick: handleDelete, + }, + ], + }, + ]} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/inventory-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/inventory-list-table.tsx new file mode 100644 index 0000000000..dd00724ce8 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/inventory-list-table.tsx @@ -0,0 +1,61 @@ +import { Container, Heading } from "@medusajs/ui" + +import { DataTable } from "../../../../components/table/data-table" +import { InventoryNext } from "@medusajs/types" +import { useDataTable } from "../../../../hooks/use-data-table" +import { useInventoryItems } from "../../../../hooks/api/inventory" +import { useInventoryTableColumns } from "./use-inventory-table-columns" +import { useInventoryTableFilters } from "./use-inventory-table-filters" +import { useInventoryTableQuery } from "./use-inventory-table-query" +import { useTranslation } from "react-i18next" + +const PAGE_SIZE = 20 + +export const InventoryListTable = () => { + const { t } = useTranslation() + + const { searchParams, raw } = useInventoryTableQuery({ + pageSize: PAGE_SIZE, + }) + const { inventory_items, count, isLoading, isError, error } = + useInventoryItems({ + ...searchParams, + }) + + const filters = useInventoryTableFilters() + const columns = useInventoryTableColumns() + + const { table } = useDataTable({ + data: (inventory_items ?? []) as InventoryNext.InventoryItemDTO[], + columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + }) + + if (isError) { + throw error + } + + return ( + +
+ {t("inventory.domain")} +
+ `${row.id}`} + /> +
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-columns.tsx new file mode 100644 index 0000000000..fe94defdf4 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-columns.tsx @@ -0,0 +1,96 @@ +import { InventoryNext, ProductVariantDTO } from "@medusajs/types" + +import { InventoryActions } from "./inventory-actions" +import { PlaceholderCell } from "../../../../components/table/table-cells/common/placeholder-cell" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +/** + * Adds missing properties to the InventoryItemDTO type. + */ +interface ExtendedInventoryItem extends InventoryNext.InventoryItemDTO { + variants?: ProductVariantDTO[] | null + stocked_quantity?: number + reserved_quantity?: number +} + +const columnHelper = createColumnHelper() + +export const useInventoryTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("title", { + header: t("fields.title"), + cell: ({ getValue }) => { + const title = getValue() + + if (!title) { + return + } + + return ( +
+ {title} +
+ ) + }, + }), + columnHelper.accessor("sku", { + header: t("fields.sku"), + cell: ({ getValue }) => { + const sku = getValue() as string + + if (!sku) { + return + } + + return ( +
+ {sku} +
+ ) + }, + }), + columnHelper.accessor("reserved_quantity", { + header: t("inventory.reserved"), + cell: ({ getValue }) => { + const quantity = getValue() + + if (Number.isNaN(quantity)) { + return + } + + return ( +
+ {quantity} +
+ ) + }, + }), + columnHelper.accessor("stocked_quantity", { + header: t("fields.inStock"), + cell: ({ getValue }) => { + const quantity = getValue() + + if (Number.isNaN(quantity)) { + return + } + + return ( +
+ {quantity} +
+ ) + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => , + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-filters.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-filters.tsx new file mode 100644 index 0000000000..e2be67e1b2 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-filters.tsx @@ -0,0 +1,82 @@ +import { Filter } from "../../../../components/table/data-table" +import { useStockLocations } from "../../../../hooks/api/stock-locations" +import { useTranslation } from "react-i18next" + +export const useInventoryTableFilters = () => { + const { t } = useTranslation() + const { stock_locations } = useStockLocations({ + limit: 1000, + }) + + const filters: Filter[] = [] + + if (stock_locations) { + const stockLocationFilter: Filter = { + type: "select", + options: stock_locations.map((s) => ({ + label: s.name, + value: s.id, + })), + key: "location_id", + searchable: true, + label: t("fields.location"), + } + + filters.push(stockLocationFilter) + } + + filters.push({ + type: "string", + key: "material", + label: t("fields.material"), + }) + + filters.push({ + type: "string", + key: "sku", + label: t("fields.sku"), + }) + + filters.push({ + type: "string", + key: "mid_code", + label: t("fields.midCode"), + }) + + filters.push({ + type: "number", + key: "height", + label: t("fields.height"), + }) + + filters.push({ + type: "number", + key: "width", + label: t("fields.width"), + }) + + filters.push({ + type: "number", + key: "length", + label: t("fields.length"), + }) + + filters.push({ + type: "number", + key: "weight", + label: t("fields.weight"), + }) + + filters.push({ + type: "select", + options: [ + { label: t("fields.true"), value: "true" }, + { label: t("fields.false"), value: "false" }, + ], + key: "requires_shipping", + multiple: false, + label: t("fields.requiresShipping"), + }) + + return filters +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-query.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-query.tsx new file mode 100644 index 0000000000..d02f52f891 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/components/use-inventory-table-query.tsx @@ -0,0 +1,57 @@ +import { AdminGetInventoryItemsParams } from "@medusajs/medusa" +import { useQueryParams } from "../../../../hooks/use-query-params" + +export const useInventoryTableQuery = ({ + pageSize = 20, + prefix, +}: { + pageSize?: number + prefix?: string +}) => { + const raw = useQueryParams( + [ + "location_id", + "q", + "order", + "requires_shipping", + "offset", + "sku", + "material", + "mid_code", + "order", + "weight", + "width", + "length", + "height", + ], + prefix + ) + + const { + offset, + weight, + width, + length, + height, + requires_shipping, + ...params + } = raw + + const searchParams: AdminGetInventoryItemsParams = { + limit: pageSize, + offset: offset ? parseInt(offset) : undefined, + weight: weight ? JSON.parse(weight) : undefined, + width: width ? JSON.parse(width) : undefined, + length: length ? JSON.parse(length) : undefined, + height: height ? JSON.parse(height) : undefined, + requires_shipping: requires_shipping + ? JSON.parse(requires_shipping) + : undefined, + ...params, + } + + return { + searchParams, + raw, + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/index.ts b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/index.ts new file mode 100644 index 0000000000..4befcefcad --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/index.ts @@ -0,0 +1 @@ +export { InventoryItemListTable as Component } from "./inventory-list" diff --git a/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/inventory-list.tsx b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/inventory-list.tsx new file mode 100644 index 0000000000..c94acfdf47 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/inventory/inventory-list/inventory-list.tsx @@ -0,0 +1,11 @@ +import { InventoryListTable } from "./components/inventory-list-table" +import { Outlet } from "react-router-dom" + +export const InventoryItemListTable = () => { + return ( +
+ + +
+ ) +} diff --git a/packages/core-flows/src/inventory/steps/create-inventory-levels.ts b/packages/core-flows/src/inventory/steps/create-inventory-levels.ts index 9453c81cf1..7bfa4f5b43 100644 --- a/packages/core-flows/src/inventory/steps/create-inventory-levels.ts +++ b/packages/core-flows/src/inventory/steps/create-inventory-levels.ts @@ -1,7 +1,4 @@ -import { - CreateInventoryLevelInput, - IInventoryServiceNext, -} from "@medusajs/types" +import { IInventoryServiceNext, InventoryNext } from "@medusajs/types" import { StepResponse, createStep } from "@medusajs/workflows-sdk" import { ModuleRegistrationName } from "@medusajs/modules-sdk" @@ -9,7 +6,7 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" export const createInventoryLevelsStepId = "create-inventory-levels" export const createInventoryLevelsStep = createStep( createInventoryLevelsStepId, - async (data: CreateInventoryLevelInput[], { container }) => { + async (data: InventoryNext.CreateInventoryLevelInput[], { container }) => { const service = container.resolve( ModuleRegistrationName.INVENTORY ) diff --git a/packages/core-flows/src/inventory/steps/delete-levels-by-item-and-location.ts b/packages/core-flows/src/inventory/steps/delete-levels-by-item-and-location.ts new file mode 100644 index 0000000000..b8140972a4 --- /dev/null +++ b/packages/core-flows/src/inventory/steps/delete-levels-by-item-and-location.ts @@ -0,0 +1,62 @@ +import { + DeleteEntityInput, + ModuleRegistrationName, + Modules, +} from "@medusajs/modules-sdk" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +import { IInventoryServiceNext } from "@medusajs/types" +import { MedusaError } from "@medusajs/utils" + +export const deleteInventoryLevelsFromItemAndLocationsStepId = + "delete-inventory-levels-from-item-and-location-step" +export const deleteInventoryLevelsFromItemAndLocationsStep = createStep( + deleteInventoryLevelsFromItemAndLocationsStepId, + async ( + input: { inventory_item_id: string; location_id: string }[], + { container } + ) => { + if (!input.length) { + return new StepResponse(void 0, []) + } + + const service = container.resolve( + ModuleRegistrationName.INVENTORY + ) + + const items = await service.listInventoryLevels({ $or: input }, {}) + + if (items.some((i) => i.reserved_quantity > 0)) { + const invalidDeletes = items.filter((i) => i.reserved_quantity > 0) + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + `Cannot remove Inventory Levels for ${invalidDeletes + .map((i) => `Inventory Level ${i.id} at Location ${i.location_id}`) + .join( + ", " + )} because there are reserved quantities for items at locations` + ) + } + + const deletedIds = items.map((i) => i.id) + const deleted = await service.softDeleteInventoryLevels(deletedIds) + + return new StepResponse( + { + [Modules.INVENTORY]: deleted, + } as DeleteEntityInput, + deletedIds + ) + }, + async (prevLevelIds, { container }) => { + if (!prevLevelIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.INVENTORY + ) + + await service.restoreInventoryLevels(prevLevelIds) + } +) diff --git a/packages/core-flows/src/inventory/steps/index.ts b/packages/core-flows/src/inventory/steps/index.ts index 4b66716c36..03a832caad 100644 --- a/packages/core-flows/src/inventory/steps/index.ts +++ b/packages/core-flows/src/inventory/steps/index.ts @@ -7,3 +7,4 @@ export * from "./validate-inventory-locations" export * from "./update-inventory-items" export * from "./delete-inventory-levels" export * from "./update-inventory-levels" +export * from "./delete-levels-by-item-and-location" diff --git a/packages/core-flows/src/inventory/steps/validate-inventory-locations.ts b/packages/core-flows/src/inventory/steps/validate-inventory-locations.ts index cd93e2560c..ee30ccdfea 100644 --- a/packages/core-flows/src/inventory/steps/validate-inventory-locations.ts +++ b/packages/core-flows/src/inventory/steps/validate-inventory-locations.ts @@ -5,13 +5,13 @@ import { remoteQueryObjectFromString, } from "@medusajs/utils" -import { CreateInventoryLevelInput } from "@medusajs/types" +import { InventoryNext } from "@medusajs/types" import { createStep } from "@medusajs/workflows-sdk" export const validateInventoryLocationsStepId = "validate-inventory-levels-step" export const validateInventoryLocationsStep = createStep( validateInventoryLocationsStepId, - async (data: CreateInventoryLevelInput[], { container }) => { + async (data: InventoryNext.CreateInventoryLevelInput[], { container }) => { const remoteQuery = container.resolve( ContainerRegistrationKeys.REMOTE_QUERY ) diff --git a/packages/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts b/packages/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts new file mode 100644 index 0000000000..966629dddb --- /dev/null +++ b/packages/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts @@ -0,0 +1,26 @@ +import { InventoryLevelDTO, InventoryNext } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { + createInventoryLevelsStep, + deleteInventoryLevelsFromItemAndLocationsStep, +} from "../steps" + +import { removeRemoteLinkStep } from "../../common" + +interface WorkflowInput { + creates: InventoryNext.CreateInventoryLevelInput[] + deletes: { inventory_item_id: string; location_id: string }[] +} + +export const bulkCreateDeleteLevelsWorkflowId = + "bulk-create-delete-levels-workflow" +export const bulkCreateDeleteLevelsWorkflow = createWorkflow( + bulkCreateDeleteLevelsWorkflowId, + (input: WorkflowData): WorkflowData => { + const deleted = deleteInventoryLevelsFromItemAndLocationsStep(input.deletes) + + removeRemoteLinkStep(deleted) + + return createInventoryLevelsStep(input.creates) + } +) diff --git a/packages/core-flows/src/inventory/workflows/create-inventory-levels.ts b/packages/core-flows/src/inventory/workflows/create-inventory-levels.ts index e39b4f4385..7ec709edca 100644 --- a/packages/core-flows/src/inventory/workflows/create-inventory-levels.ts +++ b/packages/core-flows/src/inventory/workflows/create-inventory-levels.ts @@ -1,4 +1,4 @@ -import { CreateInventoryLevelInput, InventoryLevelDTO } from "@medusajs/types" +import { InventoryLevelDTO, InventoryNext } from "@medusajs/types" import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" import { createInventoryLevelsStep, @@ -6,7 +6,7 @@ import { } from "../steps" interface WorkflowInput { - inventory_levels: CreateInventoryLevelInput[] + inventory_levels: InventoryNext.CreateInventoryLevelInput[] } export const createInventoryLevelsWorkflowId = "create-inventory-levels-workflow" diff --git a/packages/core-flows/src/inventory/workflows/index.ts b/packages/core-flows/src/inventory/workflows/index.ts index d59c844079..5d5c413e5d 100644 --- a/packages/core-flows/src/inventory/workflows/index.ts +++ b/packages/core-flows/src/inventory/workflows/index.ts @@ -4,3 +4,4 @@ export * from "./create-inventory-levels" export * from "./update-inventory-items" export * from "./delete-inventory-levels" export * from "./update-inventory-levels" +export * from "./bulk-create-delete-levels" diff --git a/packages/link-modules/src/definitions/product-variant-inventory-item.ts b/packages/link-modules/src/definitions/product-variant-inventory-item.ts index e17273fe4a..d618e1359f 100644 --- a/packages/link-modules/src/definitions/product-variant-inventory-item.ts +++ b/packages/link-modules/src/definitions/product-variant-inventory-item.ts @@ -1,6 +1,6 @@ -import { Modules } from "@medusajs/modules-sdk" -import { ModuleJoinerConfig } from "@medusajs/types" import { LINKS } from "@medusajs/utils" +import { ModuleJoinerConfig } from "@medusajs/types" +import { Modules } from "@medusajs/modules-sdk" export const ProductVariantInventoryItem: ModuleJoinerConfig = { serviceName: LINKS.ProductVariantInventoryItem, @@ -58,6 +58,9 @@ export const ProductVariantInventoryItem: ModuleJoinerConfig = { }, { serviceName: Modules.INVENTORY, + fieldAlias: { + variant: "variant_link.variant", + }, relationship: { serviceName: LINKS.ProductVariantInventoryItem, primaryKey: "inventory_item_id", diff --git a/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/batch/combi/route.ts b/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/batch/combi/route.ts new file mode 100644 index 0000000000..cf87430fa4 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/batch/combi/route.ts @@ -0,0 +1,55 @@ +import { + AdminPostInventoryItemsItemLocationLevelsBatchReq, + AdminPostInventoryItemsItemLocationLevelsReq, +} from "../../../../validators" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { + MedusaRequest, + MedusaResponse, +} from "../../../../../../../types/routing" + +import { bulkCreateDeleteLevelsWorkflow } from "@medusajs/core-flows" +import { defaultAdminInventoryItemFields } from "../../../../query-config" + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const workflow = bulkCreateDeleteLevelsWorkflow(req.scope) + const { errors } = await workflow.run({ + input: { + deletes: req.validatedBody.deletes.map((location_id) => ({ + location_id, + inventory_item_id: id, + })), + creates: req.validatedBody.creates.map((c) => ({ + ...c, + inventory_item_id: id, + })), + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const itemQuery = remoteQueryObjectFromString({ + entryPoint: "inventory_items", + variables: { + id, + }, + fields: defaultAdminInventoryItemFields, + }) + + const [inventory_item] = await remoteQuery(itemQuery) + + res.status(200).json({ inventory_item }) +} diff --git a/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/route.ts b/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/route.ts index 386fc5aaa9..a078f6d8f0 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/route.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/[id]/location-levels/route.ts @@ -54,9 +54,7 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { entryPoint: "inventory_levels", variables: { filters: req.filterableFields, - order: req.listConfig.order, - skip: req.listConfig.skip, - take: req.listConfig.take, + ...req.remoteQueryConfig.pagination, }, fields: req.remoteQueryConfig.fields, }) diff --git a/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts b/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts index 2067c97cdf..dcdebe1961 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/middlewares.ts @@ -6,6 +6,7 @@ import { AdminGetInventoryItemsParams, AdminPostInventoryItemsInventoryItemParams, AdminPostInventoryItemsInventoryItemReq, + AdminPostInventoryItemsItemLocationLevelsBatchReq, AdminPostInventoryItemsItemLocationLevelsLevelParams, AdminPostInventoryItemsItemLocationLevelsLevelReq, AdminPostInventoryItemsItemLocationLevelsReq, @@ -53,6 +54,13 @@ export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/inventory-items/:id/location-levels/batch/combi", + middlewares: [ + transformBody(AdminPostInventoryItemsItemLocationLevelsBatchReq), + ], + }, { method: ["GET"], matcher: "/admin/inventory-items/:id/location-levels", diff --git a/packages/medusa/src/api-v2/admin/inventory-items/query-config.ts b/packages/medusa/src/api-v2/admin/inventory-items/query-config.ts index e4a158a652..f01bc2143d 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/query-config.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/query-config.ts @@ -1,4 +1,5 @@ import { InventoryNext } from "@medusajs/types" +import { defaultAdminProductsVariantFields } from "../products/query-config" // eslint-disable-next-line max-len export const defaultAdminLocationLevelFields = [ @@ -37,17 +38,18 @@ export const defaultAdminInventoryItemFields = [ ...defaultAdminLocationLevelFields.map( (field) => `location_levels.${field.toString()}` ), + ...defaultAdminProductsVariantFields + .filter((field) => !field.startsWith("*")) + .map((field) => `variant.${field}`), ] export const retrieveTransformQueryConfig = { defaults: defaultAdminInventoryItemFields, - allowed: defaultAdminInventoryItemFields, isList: false, } export const retrieveLocationLevelsTransformQueryConfig = { defaults: defaultAdminLocationLevelFields, - allowed: defaultAdminLocationLevelFields, isList: false, } diff --git a/packages/medusa/src/api-v2/admin/inventory-items/validators.ts b/packages/medusa/src/api-v2/admin/inventory-items/validators.ts index ae99b82084..776556dc63 100644 --- a/packages/medusa/src/api-v2/admin/inventory-items/validators.ts +++ b/packages/medusa/src/api-v2/admin/inventory-items/validators.ts @@ -127,13 +127,23 @@ export class AdminPostInventoryItemsItemLocationLevelsReq { location_id: string @IsNumber() - stocked_quantity: number + @IsOptional() + stocked_quantity?: number @IsOptional() @IsNumber() incoming_quantity?: number } +export class AdminPostInventoryItemsItemLocationLevelsBatchReq { + @ValidateNested({ each: true }) + @Type(() => AdminPostInventoryItemsItemLocationLevelsReq) + creates: AdminPostInventoryItemsItemLocationLevelsReq[] + + @IsString({ each: true }) + deletes: string[] +} + // eslint-disable-next-line export class AdminPostInventoryItemsItemLocationLevelsParams extends FindParams {} @@ -259,7 +269,13 @@ export class AdminPostInventoryItemsReq { metadata?: Record } -export class AdminGetInventoryItemsItemLocationLevelsParams extends FindParams { +// eslint-disable-next-line max-len +export class AdminGetInventoryItemsItemLocationLevelsParams extends extendedFindParamsMixin( + { + limit: 50, + offset: 0, + } +) { /** * Location IDs to filter location levels. */ diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index d1a3385efd..4687fc1344 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -70,6 +70,7 @@ export const config: MiddlewaresConfig = { ...adminProductTypeRoutesMiddlewares, ...adminUploadRoutesMiddlewares, ...adminFulfillmentSetsRoutesMiddlewares, + ...adminReservationRoutesMiddlewares, ...adminProductCategoryRoutesMiddlewares, ...adminReservationRoutesMiddlewares, ...adminShippingProfilesMiddlewares, diff --git a/packages/types/src/http/index.ts b/packages/types/src/http/index.ts index 6bdcee2941..b5fc0360d2 100644 --- a/packages/types/src/http/index.ts +++ b/packages/types/src/http/index.ts @@ -1,6 +1,7 @@ export * from "./api-key" export * from "./customer" export * from "./fulfillment" +export * from "./inventory" export * from "./pricing" export * from "./sales-channel" export * from "./stock-locations" diff --git a/packages/types/src/http/inventory/index.ts b/packages/types/src/http/inventory/index.ts new file mode 100644 index 0000000000..8671027bb3 --- /dev/null +++ b/packages/types/src/http/inventory/index.ts @@ -0,0 +1,2 @@ +export * from "./inventory" +export * from "./inventory-level" diff --git a/packages/types/src/http/inventory/inventory-level.ts b/packages/types/src/http/inventory/inventory-level.ts new file mode 100644 index 0000000000..e0e51c68e8 --- /dev/null +++ b/packages/types/src/http/inventory/inventory-level.ts @@ -0,0 +1,26 @@ +import { AdminInventoryItemResponse } from "./inventory" +import { PaginatedResponse } from "../common" + +interface InventoryLevelResponse { + id: string + inventory_item_id: string + location_id: string + stocked_quantity: number + reserved_quantity: number + available_quantity: number + incoming_quantity: number + metadata?: Record | null +} +/** + * @experimental + */ +export interface AdminInventoryLevelResponse { + inventory_item: AdminInventoryItemResponse +} + +/** + * @experimental + */ +export interface AdminInventoryLevelListResponse extends PaginatedResponse { + inventory_levels: InventoryLevelResponse[] +} diff --git a/packages/types/src/http/inventory/inventory.ts b/packages/types/src/http/inventory/inventory.ts new file mode 100644 index 0000000000..e3eaba5196 --- /dev/null +++ b/packages/types/src/http/inventory/inventory.ts @@ -0,0 +1,32 @@ +import { PaginatedResponse } from "../common" + +interface InventoryItemResponse { + id: string + sku?: string | null + origin_country?: string | null + hs_code?: string | null + requires_shipping: boolean + mid_code?: string | null + material?: string | null + weight?: number | null + length?: number | null + height?: number | null + width?: number | null + title?: string | null + description?: string | null + thumbnail?: string | null + metadata?: Record | null +} +/** + * @experimental + */ +export interface AdminInventoryItemResponse { + inventory_item: InventoryItemResponse +} + +/** + * @experimental + */ +export interface AdminInventoryItemListResponse extends PaginatedResponse { + inventory_items: InventoryItemResponse[] +} diff --git a/packages/types/src/inventory/common/inventory-level.ts b/packages/types/src/inventory/common/inventory-level.ts index e0f239ebba..252a4a26b4 100644 --- a/packages/types/src/inventory/common/inventory-level.ts +++ b/packages/types/src/inventory/common/inventory-level.ts @@ -1,7 +1,5 @@ import { BaseFilterable, OperatorMap } from "../../dal" -import { NumericalComparisonOperator } from "../../common" - /** * The inventory level details. */ diff --git a/packages/types/src/inventory/mutations/inventory-level.ts b/packages/types/src/inventory/mutations/inventory-level.ts index d8c69052c0..b2f6830a27 100644 --- a/packages/types/src/inventory/mutations/inventory-level.ts +++ b/packages/types/src/inventory/mutations/inventory-level.ts @@ -10,7 +10,7 @@ export interface CreateInventoryLevelInput { /** * The stocked quantity of the associated inventory item in the associated location. */ - stocked_quantity: number + stocked_quantity?: number /** * The reserved quantity of the associated inventory item in the associated location. */