From 8703f453be7877152a5f51a777ddbd686b8b4cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Wed, 26 Jun 2024 11:53:16 +0200 Subject: [PATCH] feat(dashboard): manage inventory items for variants (#7800) * feat: manage inventory items for variants * fix: show action only when manage inventory * fix: translation key * fix: labels depending on number of items * fix: address feedback --------- Co-authored-by: Sebastian Rindom --- .../dashboard/src/hooks/api/products.tsx | 21 ++ .../dashboard/src/i18n/translations/en.json | 13 +- .../providers/router-provider/route-map.tsx | 7 + .../inventory-actions.tsx | 2 +- .../variant-inventory-section.tsx | 38 ++- .../product-variant-detail.tsx | 1 + .../index.ts | 1 + .../manage-variant-inventory-items-form.tsx | 307 ++++++++++++++++++ .../index.ts | 1 + ...product-variant-manage-inventory-items.tsx | 31 ++ packages/core/js-sdk/src/admin/product.ts | 34 +- .../types/src/http/product/admin/payloads.ts | 25 ++ .../types/src/http/product/admin/responses.ts | 4 + 13 files changed, 467 insertions(+), 18 deletions(-) create mode 100644 packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/components/manage-variant-inventory-items-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/components/manage-variant-inventory-items-form/manage-variant-inventory-items-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/product-variant-manage-inventory-items.tsx diff --git a/packages/admin-next/dashboard/src/hooks/api/products.tsx b/packages/admin-next/dashboard/src/hooks/api/products.tsx index b3437e20cd..b9b6ea2c50 100644 --- a/packages/admin-next/dashboard/src/hooks/api/products.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/products.tsx @@ -185,6 +185,27 @@ export const useUpdateProductVariantsBatch = ( }) } +export const useProductVariantsInventoryItemsBatch = ( + productId: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: ( + payload: HttpTypes.AdminBatchProductVariantInventoryItemRequest + ) => sdk.admin.product.batchVariantInventoryItems(productId, payload), + onSuccess: (data: any, variables: any, context: any) => { + queryClient.invalidateQueries({ queryKey: variantsQueryKeys.lists() }) + queryClient.invalidateQueries({ queryKey: variantsQueryKeys.details() }) + queryClient.invalidateQueries({ + queryKey: productsQueryKeys.detail(productId), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + export const useDeleteVariant = ( productId: string, variantId: string, diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 79b82564ae..73267e10a5 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -367,10 +367,17 @@ "tableItem_other": "{{availableCount}} available at {{locationCount}} locations", "inventory": { "notManaged": "Not managed", + "manageItems": "Manage inventory items", + "manageKit": "Manage inventory kit", + "navigateToItem": "Go to inventory item", "actions": { "inventoryItems": "Go to inventory item", "inventoryKit": "Show inventory items" }, + "validation": { + "itemId": "Please select inventory item.", + "quantity": "Quantity is required. Please input a positive number." + }, "header": "Stock & Inventory", "editItemDetails": "Edit item details", "manageInventoryLabel": "Manage inventory", @@ -379,8 +386,9 @@ "allowBackordersHint": "When enabled the variant can be sold even if the inventory level is below zero.", "toast": { "levelsBatch": "Inventory levels updated.", - "update": "Inventory item updated successfully", - "updateLevel": "Inventory level updated successfully" + "update": "Inventory item updated successfully.", + "updateLevel": "Inventory level updated successfully.", + "itemsManageSuccess": "Inventory items sucessfully updated." } } }, @@ -1741,6 +1749,7 @@ "manageInventory": "Manage inventory", "inventoryKit": "Inventory kit", "inventoryItems": "Inventory items", + "inventoryItem": "Inventory item", "requiredQuantity": "Required quantity", "description": "Description", "email": "Email", diff --git a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx index b5dd9eb506..7b6714b62d 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/route-map.tsx @@ -130,6 +130,13 @@ export const RouteMap: RouteObject[] = [ path: "prices", lazy: () => import("../../routes/products/product-prices"), }, + { + path: "manage-items", + lazy: () => + import( + "../../routes/product-variants/product-variant-manage-inventory-items" + ), + }, ], }, ], diff --git a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/components/variant-inventory-section/inventory-actions.tsx b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/components/variant-inventory-section/inventory-actions.tsx index cb0480849d..f3caba5c0b 100644 --- a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/components/variant-inventory-section/inventory-actions.tsx +++ b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/components/variant-inventory-section/inventory-actions.tsx @@ -15,7 +15,7 @@ export const InventoryActions = ({ item }: { item: InventoryItemDTO }) => { actions: [ { icon: , - label: t("product.variant.inventory.navigateToItem"), + label: t("products.variant.inventory.navigateToItem"), to: `/inventory/${item.id}`, }, ], diff --git a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/components/variant-inventory-section/variant-inventory-section.tsx b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/components/variant-inventory-section/variant-inventory-section.tsx index 9be09d36f4..91c5b16625 100644 --- a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/components/variant-inventory-section/variant-inventory-section.tsx +++ b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/components/variant-inventory-section/variant-inventory-section.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next" +import { Buildings, Component } from "@medusajs/icons" import { Container, Heading } from "@medusajs/ui" import { InventoryItemDTO } from "@medusajs/types" @@ -13,10 +14,12 @@ const PAGE_SIZE = 20 type VariantInventorySectionProps = { inventoryItems: InventoryItemDTO[] + manageInventory?: boolean } export function VariantInventorySection({ inventoryItems, + manageInventory, }: VariantInventorySectionProps) { const { t } = useTranslation() @@ -31,6 +34,8 @@ export function VariantInventorySection({ pageSize: PAGE_SIZE, }) + const hasKit = inventoryItems.length > 1 + return (
@@ -38,20 +43,25 @@ export function VariantInventorySection({ {t("fields.inventoryItems")}
- {/*TODO: add inventory management*/} - {/*,*/} - {/* },*/} - {/* ],*/} - {/* },*/} - {/* ]}*/} - {/*/>*/} + {manageInventory && ( + : , + }, + ], + }, + ]} + /> + )}
diff --git a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/product-variant-detail.tsx b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/product-variant-detail.tsx index 9b0433cb49..410f9f9380 100644 --- a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/product-variant-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-detail/product-variant-detail.tsx @@ -45,6 +45,7 @@ export const ProductVariantDetail = () => { variant, } })} + manageInventory={variant.manage_inventory} />
diff --git a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/components/manage-variant-inventory-items-form/index.ts b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/components/manage-variant-inventory-items-form/index.ts new file mode 100644 index 0000000000..9abc2a0ebf --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/components/manage-variant-inventory-items-form/index.ts @@ -0,0 +1 @@ +export * from "./manage-variant-inventory-items-form" diff --git a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/components/manage-variant-inventory-items-form/manage-variant-inventory-items-form.tsx b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/components/manage-variant-inventory-items-form/manage-variant-inventory-items-form.tsx new file mode 100644 index 0000000000..2cc45042f8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/components/manage-variant-inventory-items-form/manage-variant-inventory-items-form.tsx @@ -0,0 +1,307 @@ +import { Button, Heading, IconButton, Input, Label, toast } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { useFieldArray, useForm } from "react-hook-form" +import { XMarkMini } from "@medusajs/icons" +import * as zod from "zod" +import i18next from "i18next" +import { zodResolver } from "@hookform/resolvers/zod" +import { AdminInventoryItem, AdminProductVariant } from "@medusajs/types" + +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { Form } from "../../../../../components/common/form" +import { Combobox } from "../../../../../components/inputs/combobox" +import { useComboboxData } from "../../../../../hooks/use-combobox-data" +import { useProductVariantsInventoryItemsBatch } from "../../../../../hooks/api/products" +import { sdk } from "../../../../../lib/client" + +type ManageVariantInventoryItemsFormProps = { + variant: AdminProductVariant & { + inventory_items: { + inventory: AdminInventoryItem[] + required_quantity: number + } + } +} + +const ManageVariantInventoryItemsSchema = zod.object({ + inventory: zod.array( + zod.object({ + inventory_item_id: zod + .string() + .min(1, i18next.t("products.variant.inventory.validation.itemId")), + required_quantity: zod + .number({ + errorMap: () => ({ + message: i18next.t( + "products.variant.inventory.validation.quantity" + ), + }), + }) + .min(0, i18next.t("products.variant.inventory.validation.quantity")), + }) + ), +}) + +export function ManageVariantInventoryItemsForm({ + variant, +}: ManageVariantInventoryItemsFormProps) { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + inventory: variant.inventory_items!.map((i) => ({ + required_quantity: i.required_quantity, + inventory_item_id: i.inventory.id, + })), + }, + resolver: zodResolver(ManageVariantInventoryItemsSchema), + }) + + const inventory = useFieldArray({ + control: form.control, + name: `inventory`, + }) + + const hasKit = inventory.fields.length > 1 + + const items = useComboboxData({ + queryKey: ["inventory_items"], + queryFn: (params) => sdk.admin.inventoryItem.list(params), + getOptions: (data) => + data.inventory_items.map((item) => ({ + label: item.title, + value: item.id, + })), + }) + + const { mutateAsync, isPending } = useProductVariantsInventoryItemsBatch( + variant.product_id + ) + + const handleSubmit = form.handleSubmit(async (values) => { + const existingItems = {} + const selectedItems = {} + + variant.inventory_items.forEach( + (i) => (existingItems[i.inventory.id] = i.required_quantity) + ) + + values.inventory.forEach((i) => (selectedItems[i.inventory_item_id] = true)) + + const payload = { + create: [], + update: [], + delete: [], + } + + values.inventory.forEach((v) => { + if (v.inventory_item_id in existingItems) { + if (v.required_quantity !== existingItems[v.inventory_item_id]) { + payload.update.push({ + required_quantity: v.required_quantity, + inventory_item_id: v.inventory_item_id, + variant_id: variant.id, + }) + } + } else { + payload.create.push({ + required_quantity: v.required_quantity, + inventory_item_id: v.inventory_item_id, + variant_id: variant.id, + }) + } + }) + + variant.inventory_items.forEach((i) => { + if (!(i.inventory.id in selectedItems)) { + payload.delete.push({ + inventory_item_id: i.inventory.id, + variant_id: variant.id, + }) + } + }) + + for (const k in payload) { + if (!payload[k].length) { + delete payload[k] + } + } + + await mutateAsync(payload, { + onSuccess: () => { + toast.success(t("general.success"), { + description: t("products.variant.inventory.toast.itemsManageSuccess"), + dismissLabel: t("general.close"), + }) + handleSuccess() + }, + onError: (err) => { + toast.error(t("general.error"), { + description: err.message, + dismissLabel: t("general.close"), + }) + }, + }) + }) + + return ( + +
+ +
+ + + + +
+
+ +
+ + {t( + hasKit + ? "products.create.inventory.heading" + : "fields.inventoryItems" + )} + + +
+
+
+ {variant.title} + + {t( + hasKit + ? "products.create.inventory.label" + : "fields.inventoryItem" + )} + +
+ +
+ {inventory.fields.map((inventoryItem, inventoryIndex) => ( +
  • +
    +
    + +
    + + { + return ( + + + + + + + ) + }} + /> + +
    + +
    + { + return ( + + + { + const value = e.target.value + + if (value === "") { + onChange(null) + } else { + onChange(Number(value)) + } + }} + {...field} + placeholder={t( + "products.create.inventory.quantityPlaceholder" + )} + /> + + + + ) + }} + /> +
    + inventory.remove(inventoryIndex)} + > + + +
  • + ))} +
    +
    +
    +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/index.ts b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/index.ts new file mode 100644 index 0000000000..0b36ceac00 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/index.ts @@ -0,0 +1 @@ +export { ProductVariantManageInventoryItems as Component } from "./product-variant-manage-inventory-items" diff --git a/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/product-variant-manage-inventory-items.tsx b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/product-variant-manage-inventory-items.tsx new file mode 100644 index 0000000000..3e5d549302 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/product-variants/product-variant-manage-inventory-items/product-variant-manage-inventory-items.tsx @@ -0,0 +1,31 @@ +import { useParams } from "react-router-dom" + +import { ManageVariantInventoryItemsForm } from "./components/manage-variant-inventory-items-form" +import { RouteFocusModal } from "../../../components/route-modal" +import { useProductVariant } from "../../../hooks/api/products" +import { VARIANT_DETAIL_FIELDS } from "../product-variant-detail/constants.ts" + +export function ProductVariantManageInventoryItems() { + const { id, variant_id } = useParams() + + const { + variant, + isPending: isLoading, + isError, + error, + } = useProductVariant(id!, variant_id!, { + fields: VARIANT_DETAIL_FIELDS, + }) + + if (isError) { + throw error + } + + return ( + + {!isLoading && variant && ( + + )} + + ) +} diff --git a/packages/core/js-sdk/src/admin/product.ts b/packages/core/js-sdk/src/admin/product.ts index ec85125478..bb4e9a3939 100644 --- a/packages/core/js-sdk/src/admin/product.ts +++ b/packages/core/js-sdk/src/admin/product.ts @@ -1,4 +1,9 @@ -import { HttpTypes, SelectParams } from "@medusajs/types" +import { + AdminBatchProductVariantInventoryItemRequest, + AdminBatchProductVariantInventoryItemResponse, + HttpTypes, + SelectParams, +} from "@medusajs/types" import { Client } from "../client" import { ClientHeaders } from "../types" @@ -101,6 +106,7 @@ export class Product { } ) } + async createVariant( productId: string, body: HttpTypes.AdminCreateProductVariant, @@ -117,6 +123,7 @@ export class Product { } ) } + async updateVariant( productId: string, id: string, @@ -134,6 +141,7 @@ export class Product { } ) } + async listVariants( productId: string, queryParams?: HttpTypes.AdminProductVariantParams, @@ -147,6 +155,7 @@ export class Product { } ) } + async retrieveVariant( productId: string, id: string, @@ -161,6 +170,7 @@ export class Product { } ) } + async deleteVariant(productId: string, id: string, headers?: ClientHeaders) { return await this.client.fetch( `/admin/products/${productId}/variants/${id}`, @@ -170,6 +180,24 @@ export class Product { } ) } + + async batchVariantInventoryItems( + productId: string, + body: HttpTypes.AdminBatchProductVariantInventoryItemRequest, + query?: SelectParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/products/${productId}/variants/inventory-items/batch`, + { + method: "POST", + headers, + body, + query, + } + ) + } + async createOption( productId: string, body: HttpTypes.AdminCreateProductOption, @@ -186,6 +214,7 @@ export class Product { } ) } + async updateOption( productId: string, id: string, @@ -203,6 +232,7 @@ export class Product { } ) } + async listOptions( productId: string, queryParams?: HttpTypes.AdminProductOptionParams, @@ -216,6 +246,7 @@ export class Product { } ) } + async retrieveOption( productId: string, id: string, @@ -230,6 +261,7 @@ export class Product { } ) } + async deleteOption(productId: string, id: string, headers?: ClientHeaders) { return await this.client.fetch( `/admin/products/${productId}/options/${id}`, diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts index 4dc5b60573..4cef18d5df 100644 --- a/packages/core/types/src/http/product/admin/payloads.ts +++ b/packages/core/types/src/http/product/admin/payloads.ts @@ -3,12 +3,20 @@ import { ProductStatus } from "../common" export interface AdminBatchProductRequest extends BatchMethodRequest {} + export interface AdminBatchProductVariantRequest extends BatchMethodRequest< AdminCreateProductVariant, AdminUpdateProductVariant > {} +export interface AdminBatchProductVariantInventoryItemRequest + extends BatchMethodRequest< + AdminCreateProductVariantInventoryItem, + AdminUpdateProductVariantInventoryItem, + AdminDeleteProductVariantInventoryItem + > {} + export interface AdminCreateProductVariantPrice { currency_code: string amount: number @@ -125,3 +133,20 @@ export interface AdminUpdateProductOption { title?: string values?: string[] } + +interface AdminCreateProductVariantInventoryItem { + required_quantity: number + inventory_item_id: string + variant_id: string +} + +interface AdminUpdateProductVariantInventoryItem { + required_quantity: number + inventory_item_id: string + variant_id: string +} + +interface AdminDeleteProductVariantInventoryItem { + inventory_item_id: string + variant_id: string +} diff --git a/packages/core/types/src/http/product/admin/responses.ts b/packages/core/types/src/http/product/admin/responses.ts index 44f06d011e..584dd30958 100644 --- a/packages/core/types/src/http/product/admin/responses.ts +++ b/packages/core/types/src/http/product/admin/responses.ts @@ -5,6 +5,7 @@ import { AdminProductOption, AdminProductVariant, } from "./entitites" +import { AdminInventoryItem } from "../../inventory" export interface AdminProductResponse { product: AdminProduct @@ -33,6 +34,9 @@ export interface AdminProductVariantDeleteResponse export interface AdminBatchProductVariantResponse extends BatchMethodResponse {} +export interface AdminBatchProductVariantInventoryItemResponse + extends BatchMethodResponse {} + export interface AdminProductOptionResponse { product_option: AdminProductOption }