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 <skrindom@gmail.com>
This commit is contained in:
Frane Polić
2024-06-26 11:53:16 +02:00
committed by GitHub
co-authored by Sebastian Rindom
parent 4945c79818
commit 8703f453be
13 changed files with 467 additions and 18 deletions
@@ -185,6 +185,27 @@ export const useUpdateProductVariantsBatch = (
})
}
export const useProductVariantsInventoryItemsBatch = (
productId: string,
options?: UseMutationOptions<any, Error, any>
) => {
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,
@@ -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",
@@ -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"
),
},
],
},
],
@@ -15,7 +15,7 @@ export const InventoryActions = ({ item }: { item: InventoryItemDTO }) => {
actions: [
{
icon: <Buildings />,
label: t("product.variant.inventory.navigateToItem"),
label: t("products.variant.inventory.navigateToItem"),
to: `/inventory/${item.id}`,
},
],
@@ -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 (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
@@ -38,20 +43,25 @@ export function VariantInventorySection({
<Heading level="h2">{t("fields.inventoryItems")}</Heading>
</div>
<div className="flex items-center gap-x-4">
{/*TODO: add inventory management*/}
{/*<ActionMenu*/}
{/* groups={[*/}
{/* {*/}
{/* actions: [*/}
{/* {*/}
{/* label: t("actions.manageInventoryItems"),*/}
{/* to: "edit",*/}
{/* icon: <Component />,*/}
{/* },*/}
{/* ],*/}
{/* },*/}
{/* ]}*/}
{/*/>*/}
{manageInventory && (
<ActionMenu
groups={[
{
actions: [
{
label: t(
hasKit
? "products.variant.inventory.manageKit"
: "products.variant.inventory.manageItems"
),
to: "manage-items",
icon: hasKit ? <Component /> : <Buildings />,
},
],
},
]}
/>
)}
</div>
</div>
@@ -45,6 +45,7 @@ export const ProductVariantDetail = () => {
variant,
}
})}
manageInventory={variant.manage_inventory}
/>
<div className="hidden xl:block">
@@ -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<zod.infer<typeof ManageVariantInventoryItemsSchema>>({
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 (
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex justify-center">
<div className="flex w-full flex-col gap-y-8 px-6 pt-12 md:w-[720px] md:pt-24">
<Heading>
{t(
hasKit
? "products.create.inventory.heading"
: "fields.inventoryItems"
)}
</Heading>
<div className="grid gap-y-4">
<div className="flex items-start justify-between gap-x-4">
<div className="flex flex-col">
<Form.Label>{variant.title}</Form.Label>
<Form.Hint>
{t(
hasKit
? "products.create.inventory.label"
: "fields.inventoryItem"
)}
</Form.Hint>
</div>
<Button
size="small"
variant="secondary"
type="button"
onClick={() => {
inventory.append({
inventory_item_id: "",
required_quantity: "",
})
}}
>
{t("actions.add")}
</Button>
</div>
{inventory.fields.map((inventoryItem, inventoryIndex) => (
<li
key={inventoryItem.id}
className="bg-ui-bg-component shadow-elevation-card-rest grid grid-cols-[1fr_28px] items-center gap-1.5 rounded-xl p-1.5"
>
<div className="grid grid-cols-[min-content,1fr] items-center gap-1.5">
<div className="flex items-center px-2 py-1.5">
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`inventory.${inventoryIndex}.inventory_item_id`}
>
{t("fields.item")}
</Label>
</div>
<Form.Field
control={form.control}
name={`inventory.${inventoryIndex}.inventory_item_id`}
render={({ field }) => {
return (
<Form.Item>
<Form.Control>
<Combobox
{...field}
options={items.options}
searchValue={items.searchValue}
onSearchValueChange={items.onSearchValueChange}
fetchNextPage={items.fetchNextPage}
className="bg-ui-bg-field-component hover:bg-ui-bg-field-component-hover"
placeholder={t(
"products.create.inventory.itemPlaceholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<div className="flex items-center px-2 py-1.5">
<Label
size="xsmall"
weight="plus"
className="text-ui-fg-subtle"
htmlFor={`inventory.${inventoryIndex}.required_quantity`}
>
{t("fields.quantity")}
</Label>
</div>
<Form.Field
control={form.control}
name={`inventory.${inventoryIndex}.required_quantity`}
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Control>
<Input
type="number"
className="bg-ui-bg-field-component"
min={0}
value={value}
onChange={(e) => {
const value = e.target.value
if (value === "") {
onChange(null)
} else {
onChange(Number(value))
}
}}
{...field}
placeholder={t(
"products.create.inventory.quantityPlaceholder"
)}
/>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<IconButton
type="button"
size="small"
variant="transparent"
className="text-ui-fg-muted"
onClick={() => inventory.remove(inventoryIndex)}
>
<XMarkMini />
</IconButton>
</li>
))}
</div>
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
@@ -0,0 +1 @@
export { ProductVariantManageInventoryItems as Component } from "./product-variant-manage-inventory-items"
@@ -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 (
<RouteFocusModal>
{!isLoading && variant && (
<ManageVariantInventoryItemsForm variant={variant} />
)}
</RouteFocusModal>
)
}