feat(inventory,dashboard,types,core-flows,js-sdk,medusa): Improve inventory UX (#10630)

* feat(dashboard): Add UI for bulk editing inventory stock (#10556)

* progress

* cleanup types

* add changeset

* fix 0 values

* format schema

* add delete event and allow copy/pasting enabled for some fields

* add response types

* add tests

* work on fixing setValue behaviour

* cleanup toggle logic

* add loading state

* format schema

* add support for bidirectional actions in DataGrid and update Checkbox and RadioGroup

* update lock

* lint

* fix 404

* address feedback

* update cursor on bidirectional select
This commit is contained in:
Kasper Fabricius Kristensen
2025-01-12 19:07:14 -05:00
committed by GitHub
parent c5915451b8
commit bc22b81cdf
82 changed files with 2720 additions and 289 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"@medusajs/inventory": patch
"@medusajs/dashboard": patch
"@medusajs/core-flows": patch
"@medusajs/js-sdk": patch
"@medusajs/types": patch
"@medusajs/medusa": patch
---
feat(inventory,dashboard,core-flows,js-sdk,types,medusa): Improve inventory management UX
@@ -12,7 +12,7 @@ medusaIntegrationTestRunner({
let inventoryItem2
let stockLocation1
let stockLocation2
let stockLocation3
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, getContainer())
@@ -24,6 +24,10 @@ medusaIntegrationTestRunner({
await api.post(`/admin/stock-locations`, { name: "loc2" }, adminHeaders)
).data.stock_location
stockLocation3 = (
await api.post(`/admin/stock-locations`, { name: "loc3" }, adminHeaders)
).data.stock_location
inventoryItem1 = (
await api.post(
`/admin/inventory-items`,
@@ -122,9 +126,152 @@ medusaIntegrationTestRunner({
})
})
describe("POST /admin/inventory-items/:id/location-levels/batch", () => {
describe("POST /admin/inventory-items/location-levels/batch", () => {
let locationLevel1
let locationLevel2
beforeEach(async () => {
await api.post(
const seed = await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
{
create: [
{
location_id: stockLocation1.id,
stocked_quantity: 0,
},
{
location_id: stockLocation2.id,
stocked_quantity: 10,
},
],
},
adminHeaders
)
locationLevel1 = seed.data.created[0]
locationLevel2 = seed.data.created[1]
})
it("should batch update the inventory levels", async () => {
const result = await api.post(
`/admin/inventory-items/location-levels/batch`,
{
update: [
{
location_id: stockLocation1.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 10,
},
{
location_id: stockLocation2.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 20,
},
],
},
adminHeaders
)
expect(result.status).toEqual(200)
expect(result.data).toEqual(
expect.objectContaining({
updated: expect.arrayContaining([
expect.objectContaining({
location_id: stockLocation1.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 10,
}),
expect.objectContaining({
location_id: stockLocation2.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 20,
}),
]),
})
)
})
it("should batch create the inventory levels", async () => {
const result = await api.post(
`/admin/inventory-items/location-levels/batch`,
{
create: [
{
location_id: stockLocation3.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 10,
},
],
},
adminHeaders
)
expect(result.status).toEqual(200)
expect(result.data).toEqual(
expect.objectContaining({
created: expect.arrayContaining([
expect.objectContaining({
location_id: stockLocation3.id,
inventory_item_id: inventoryItem1.id,
stocked_quantity: 10,
}),
]),
})
)
})
it("should batch delete the inventory levels when stocked quantity is 0 and force is false", async () => {
const result = await api.post(
`/admin/inventory-items/location-levels/batch`,
{ delete: [locationLevel1.id] },
adminHeaders
)
expect(result.status).toEqual(200)
expect(result.data).toEqual(
expect.objectContaining({
deleted: [locationLevel1.id],
})
)
})
it("should not delete the inventory levels when stocked quantity is greater than 0 and force is false", async () => {
const error = await api
.post(
`/admin/inventory-items/location-levels/batch`,
{ delete: [locationLevel2.id] },
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data).toEqual({
type: "not_allowed",
message: `Cannot remove Inventory Levels for ${stockLocation2.id} because there are stocked items at the locations. Use force flag to delete anyway.`,
})
})
it("should delete the inventory levels when stocked quantity is greater than 0 and force is true", async () => {
const result = await api.post(
`/admin/inventory-items/location-levels/batch`,
{ delete: [locationLevel2.id], force: true },
adminHeaders
)
expect(result.status).toEqual(200)
expect(result.data).toEqual(
expect.objectContaining({
deleted: [locationLevel2.id],
})
)
})
})
describe("POST /admin/inventory-items/:id/location-levels/batch", () => {
let locationLevel1
beforeEach(async () => {
const seed = await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels`,
{
location_id: stockLocation1.id,
@@ -132,6 +279,8 @@ medusaIntegrationTestRunner({
},
adminHeaders
)
locationLevel1 = seed.data.inventory_item.location_levels[0]
})
it("should delete an inventory location level and create a new one", async () => {
@@ -139,7 +288,8 @@ medusaIntegrationTestRunner({
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
{
create: [{ location_id: "location_2" }],
delete: [stockLocation1.id],
delete: [locationLevel1.id],
force: true,
},
adminHeaders
)
@@ -154,7 +304,7 @@ medusaIntegrationTestRunner({
expect(levelsListResult.data.inventory_levels).toHaveLength(1)
})
it("should not delete an inventory location level when there is stocked items", async () => {
it("should not delete an inventory location level when there is stocked items without force", async () => {
await api.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels/${stockLocation1.id}`,
{ stocked_quantity: 10 },
@@ -164,7 +314,7 @@ medusaIntegrationTestRunner({
const { response } = await api
.post(
`/admin/inventory-items/${inventoryItem1.id}/location-levels/batch`,
{ delete: [stockLocation1.id] },
{ delete: [locationLevel1.id] },
adminHeaders
)
.catch((e) => e)
@@ -172,7 +322,7 @@ medusaIntegrationTestRunner({
expect(response.status).toEqual(400)
expect(response.data).toEqual({
type: "not_allowed",
message: `Cannot remove Inventory Levels for ${stockLocation1.id} because there are stocked or reserved items at the locations`,
message: `Cannot remove Inventory Levels for ${stockLocation1.id} because there are stocked items at the locations. Use force flag to delete anyway.`,
})
})
+1 -1
View File
@@ -56,12 +56,12 @@
"@uiw/react-json-view": "^2.0.0-alpha.17",
"cmdk": "^0.2.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.0.3",
"i18next": "23.7.11",
"i18next-browser-languagedetector": "7.2.0",
"i18next-http-backend": "2.4.2",
"lodash": "^4.17.21",
"match-sorter": "^6.3.4",
"motion": "^11.15.0",
"qs": "^6.12.0",
"react": "^18.2.0",
"react-country-flag": "^3.1.0",
@@ -1,4 +1,4 @@
import { motion } from "framer-motion"
import { motion } from "motion/react"
import { IconAvatar } from "../icon-avatar"
@@ -6,7 +6,7 @@ export default function AvatarBox({ checked }: { checked?: boolean }) {
return (
<IconAvatar
size="xlarge"
className="bg-ui-button-neutral shadow-buttons-neutral after:button-neutral-gradient relative mb-4 flex items-center justify-center rounded-xl after:inset-0 after:content-[''] w-[52px] h-[52px]"
className="bg-ui-button-neutral shadow-buttons-neutral after:button-neutral-gradient relative mb-4 flex h-[52px] w-[52px] items-center justify-center rounded-xl after:inset-0 after:content-['']"
>
{checked && (
<motion.div
@@ -1,5 +1,5 @@
import { clx } from "@medusajs/ui"
import { Transition, motion } from "framer-motion"
import { Transition, motion } from "motion/react"
type LogoBoxProps = {
className?: string
@@ -0,0 +1 @@
export * from "./progress-bar"
@@ -0,0 +1,33 @@
import { motion } from "motion/react"
interface ProgressBarProps {
/**
* The duration of the animation in seconds.
*
* @default 2
*/
duration?: number
}
export const ProgressBar = ({ duration = 2 }: ProgressBarProps) => {
return (
<motion.div
className="bg-ui-fg-subtle size-full"
initial={{
width: "0%",
}}
transition={{
delay: 0.2,
duration,
ease: "linear",
}}
animate={{
width: "90%",
}}
exit={{
width: "100%",
transition: { duration: 0.2, ease: "linear" },
}}
/>
)
}
@@ -11,7 +11,7 @@ export const Thumbnail = ({ src, alt, size = "base" }: ThumbnailProps) => {
return (
<div
className={clx(
"bg-ui-bg-component flex items-center justify-center overflow-hidden rounded-[4px]",
"bg-ui-bg-component border-ui-border-base flex items-center justify-center overflow-hidden rounded border",
{
"h-8 w-6": size === "base",
"h-5 w-4": size === "small",
@@ -64,7 +64,7 @@ export const DataGridCellContainer = ({
<div
{...overlayProps}
data-cell-overlay="true"
className="absolute inset-0"
className="absolute inset-0 z-[2]"
/>
)}
</div>
@@ -0,0 +1,21 @@
import { ReactNode } from "react"
import { useDataGridDuplicateCell } from "../hooks"
interface DataGridDuplicateCellProps<TValue> {
duplicateOf: string
children?: ReactNode | ((props: { value: TValue }) => ReactNode)
}
export const DataGridDuplicateCell = <TValue,>({
duplicateOf,
children,
}: DataGridDuplicateCellProps<TValue>) => {
const { watchedValue } = useDataGridDuplicateCell({ duplicateOf })
return (
<div className="bg-ui-bg-base txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none">
{typeof children === "function"
? children({ value: watchedValue })
: children}
</div>
)
}
@@ -1,24 +1,32 @@
import { PropsWithChildren } from "react"
import { clx } from "@medusajs/ui"
import { useDataGridCellError } from "../hooks"
import { DataGridCellProps } from "../types"
import { DataGridRowErrorIndicator } from "./data-grid-row-error-indicator"
type DataGridReadonlyCellProps<TData, TValue = any> = DataGridCellProps<
TData,
TValue
> &
PropsWithChildren
type DataGridReadonlyCellProps<TData, TValue = any> = PropsWithChildren<
DataGridCellProps<TData, TValue>
> & {
color?: "muted" | "normal"
}
export const DataGridReadonlyCell = <TData, TValue = any>({
context,
color = "muted",
children,
}: DataGridReadonlyCellProps<TData, TValue>) => {
const { rowErrors } = useDataGridCellError({ context })
return (
<div className="bg-ui-bg-subtle txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none">
<span className="truncate">{children}</span>
<div
className={clx(
"txt-compact-small text-ui-fg-subtle flex size-full cursor-not-allowed items-center justify-between overflow-hidden px-4 py-2.5 outline-none",
color === "muted" && "bg-ui-bg-subtle",
color === "normal" && "bg-ui-bg-base"
)}
>
<div className="flex-1 truncate">{children}</div>
<DataGridRowErrorIndicator rowErrors={rowErrors} />
</div>
)
@@ -50,7 +50,7 @@ import { isCellMatch, isSpecialFocusKey } from "../utils"
import { DataGridKeyboardShortcutModal } from "./data-grid-keyboard-shortcut-modal"
export interface DataGridRootProps<
TData,
TFieldValues extends FieldValues = FieldValues,
TFieldValues extends FieldValues = FieldValues
> {
data?: TData[]
columns: ColumnDef<TData>[]
@@ -58,6 +58,7 @@ export interface DataGridRootProps<
getSubRows?: (row: TData) => TData[] | undefined
onEditingChange?: (isEditing: boolean) => void
disableInteractions?: boolean
multiColumnSelection?: boolean
}
const ROW_HEIGHT = 40
@@ -90,13 +91,12 @@ const getCommonPinningStyles = <TData,>(
/**
* TODO:
* - [Minor] Add shortcuts overview modal.
* - [Minor] Extend the commands to also support modifying the anchor and rangeEnd, to restore the previous focus after undo/redo.
*/
export const DataGridRoot = <
TData,
TFieldValues extends FieldValues = FieldValues,
TFieldValues extends FieldValues = FieldValues
>({
data = [],
columns,
@@ -104,6 +104,7 @@ export const DataGridRoot = <
getSubRows,
onEditingChange,
disableInteractions,
multiColumnSelection = false,
}: DataGridRootProps<TData, TFieldValues>) => {
const containerRef = useRef<HTMLDivElement>(null)
@@ -231,8 +232,13 @@ export const DataGridRoot = <
}
const matrix = useMemo(
() => new DataGridMatrix<TData, TFieldValues>(flatRows, columns),
[flatRows, columns]
() =>
new DataGridMatrix<TData, TFieldValues>(
flatRows,
columns,
multiColumnSelection
),
[flatRows, columns, multiColumnSelection]
)
const queryTool = useDataGridQueryTool(containerRef)
@@ -333,6 +339,7 @@ export const DataGridRoot = <
setSelectionValues,
onEditingChangeHandler,
restoreSnapshot,
createSnapshot,
setSingleRange,
scrollToCoordinates,
execute,
@@ -390,6 +397,7 @@ export const DataGridRoot = <
setDragEnd,
setValue,
execute,
multiColumnSelection,
})
const { getCellErrorMetadata, getCellMetadata } = useDataGridCellMetadata<
@@ -655,6 +663,7 @@ export const DataGridRoot = <
virtualPaddingLeft={virtualPaddingLeft}
virtualPaddingRight={virtualPaddingRight}
onDragToFillStart={onDragToFillStart}
multiColumnSelection={multiColumnSelection}
/>
)
})}
@@ -787,6 +796,7 @@ type DataGridCellProps<TData> = {
rowIndex: number
anchor: DataGridCoordinates | null
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
multiColumnSelection: boolean
}
const DataGridCell = <TData,>({
@@ -795,6 +805,7 @@ const DataGridCell = <TData,>({
rowIndex,
anchor,
onDragToFillStart,
multiColumnSelection,
}: DataGridCellProps<TData>) => {
const coords: DataGridCoordinates = {
row: rowIndex,
@@ -828,7 +839,12 @@ const DataGridCell = <TData,>({
{isAnchor && (
<div
onMouseDown={onDragToFillStart}
className="bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize"
className={clx(
"bg-ui-fg-interactive absolute bottom-0 right-0 z-[3] size-1.5 cursor-ns-resize",
{
"cursor-nwse-resize": multiColumnSelection,
}
)}
/>
)}
</div>
@@ -846,6 +862,7 @@ type DataGridRowProps<TData> = {
flatColumns: Column<TData, unknown>[]
anchor: DataGridCoordinates | null
onDragToFillStart: (e: React.MouseEvent<HTMLElement>) => void
multiColumnSelection: boolean
}
const DataGridRow = <TData,>({
@@ -858,6 +875,7 @@ const DataGridRow = <TData,>({
flatColumns,
anchor,
onDragToFillStart,
multiColumnSelection,
}: DataGridRowProps<TData>) => {
const visibleCells = row.getVisibleCells()
@@ -904,6 +922,7 @@ const DataGridRow = <TData,>({
rowIndex={rowIndex}
anchor={anchor}
onDragToFillStart={onDragToFillStart}
multiColumnSelection={multiColumnSelection}
/>
)
@@ -0,0 +1,205 @@
import { Switch } from "@medusajs/ui"
import { useEffect, useRef, useState } from "react"
import CurrencyInput, { CurrencyInputProps } from "react-currency-input-field"
import { Controller, ControllerRenderProps } from "react-hook-form"
import { useCombinedRefs } from "../../../hooks/use-combined-refs"
import { ConditionalTooltip } from "../../common/conditional-tooltip"
import { useDataGridCell, useDataGridCellError } from "../hooks"
import { DataGridCellProps, InputProps } from "../types"
import { DataGridCellContainer } from "./data-grid-cell-container"
export const DataGridTogglableNumberCell = <TData, TValue = any>({
context,
disabledToggleTooltip,
...rest
}: DataGridCellProps<TData, TValue> & {
min?: number
max?: number
placeholder?: string
disabledToggleTooltip?: string
}) => {
const { field, control, renderProps } = useDataGridCell({
context,
})
const errorProps = useDataGridCellError({ context })
const { container, input } = renderProps
return (
<Controller
control={control}
name={field}
render={({ field }) => {
return (
<DataGridCellContainer
{...container}
{...errorProps}
outerComponent={
<OuterComponent
field={field}
inputProps={input}
isAnchor={container.isAnchor}
tooltip={disabledToggleTooltip}
/>
}
>
<Inner field={field} inputProps={input} {...rest} />
</DataGridCellContainer>
)
}}
/>
)
}
const OuterComponent = ({
field,
inputProps,
isAnchor,
tooltip,
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
isAnchor: boolean
tooltip?: string
}) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const { value } = field
const { onChange } = inputProps
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
const handleCheckedChange = (update: boolean) => {
const newValue = { ...localValue, checked: update }
if (!update && !newValue.disabledToggle) {
newValue.quantity = ""
}
if (update && newValue.quantity === "") {
newValue.quantity = 0
}
setLocalValue(newValue)
onChange(newValue, value)
}
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isAnchor && e.key.toLowerCase() === "x") {
e.preventDefault()
buttonRef.current?.click()
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [isAnchor])
return (
<ConditionalTooltip
showTooltip={localValue.disabledToggle && tooltip}
content={tooltip}
>
<div className="absolute inset-y-0 left-4 z-[3] flex w-fit items-center justify-center">
<Switch
ref={buttonRef}
size="small"
className="shrink-0"
checked={localValue.checked}
disabled={localValue.disabledToggle}
onCheckedChange={handleCheckedChange}
/>
</div>
</ConditionalTooltip>
)
}
const Inner = ({
field,
inputProps,
placeholder,
...props
}: {
field: ControllerRenderProps<any, string>
inputProps: InputProps
min?: number
max?: number
placeholder?: string
}) => {
const { ref, value, onChange: _, onBlur, ...fieldProps } = field
const {
ref: inputRef,
onChange,
onBlur: onInputBlur,
onFocus,
...attributes
} = inputProps
const [localValue, setLocalValue] = useState(value)
useEffect(() => {
setLocalValue(value)
}, [value])
const combinedRefs = useCombinedRefs(inputRef, ref)
const handleInputChange: CurrencyInputProps["onValueChange"] = (
updatedValue,
_name,
_values
) => {
const ensuredValue = updatedValue !== undefined ? updatedValue : ""
const newValue = { ...localValue, quantity: ensuredValue }
/**
* If the value is not empty, then the location should be enabled.
*
* Else, if the value is empty and the location is enabled, then the
* location should be disabled, unless toggling the location is disabled.
*/
if (ensuredValue !== "") {
newValue.checked = true
} else if (newValue.checked && newValue.disabledToggle === false) {
newValue.checked = false
}
setLocalValue(newValue)
}
const handleOnChange = () => {
if (localValue.disabledToggle && localValue.quantity === "") {
localValue.quantity = 0
}
onChange(localValue, value)
}
return (
<div className="flex size-full items-center gap-x-2">
<CurrencyInput
{...fieldProps}
{...attributes}
{...props}
ref={combinedRefs}
className="txt-compact-small w-full flex-1 cursor-default appearance-none bg-transparent pl-8 text-right outline-none"
value={localValue?.quantity}
onValueChange={handleInputChange}
formatValueOnBlur
onBlur={() => {
onBlur()
onInputBlur()
handleOnChange()
}}
onFocus={onFocus}
decimalsLimit={0}
autoComplete="off"
tabIndex={-1}
placeholder={!localValue.checked ? placeholder : undefined}
/>
</div>
)
}
@@ -5,6 +5,7 @@ export * from "./use-data-grid-cell-metadata"
export * from "./use-data-grid-cell-snapshot"
export * from "./use-data-grid-clipboard-events"
export * from "./use-data-grid-column-visibility"
export * from "./use-data-grid-duplicate-cell"
export * from "./use-data-grid-error-highlighting"
export * from "./use-data-grid-form-handlers"
export * from "./use-data-grid-keydown-event"
@@ -17,6 +17,7 @@ type UseDataGridCellHandlersOptions<TData, TFieldValues extends FieldValues> = {
setDragEnd: (coords: DataGridCoordinates | null) => void
setValue: UseFormSetValue<TFieldValues>
execute: (command: DataGridUpdateCommand) => void
multiColumnSelection?: boolean
}
export const useDataGridCellHandlers = <
@@ -36,6 +37,7 @@ export const useDataGridCellHandlers = <
setDragEnd,
setValue,
execute,
multiColumnSelection,
}: UseDataGridCellHandlersOptions<TData, TFieldValues>) => {
const getWrapperFocusHandler = useCallback(
(coords: DataGridCoordinates) => {
@@ -74,9 +76,9 @@ export const useDataGridCellHandlers = <
return (_e: MouseEvent<HTMLElement>) => {
/**
* If the column is not the same as the anchor col,
* we don't want to select the cell.
* we don't want to select the cell. Unless multiColumnSelection is true.
*/
if (anchor?.col !== coords.col) {
if (anchor?.col !== coords.col && !multiColumnSelection) {
return
}
@@ -87,7 +89,14 @@ export const useDataGridCellHandlers = <
}
}
},
[anchor?.col, isDragging, isSelecting, setDragEnd, setRangeEnd]
[
anchor?.col,
isDragging,
isSelecting,
setDragEnd,
setRangeEnd,
multiColumnSelection,
]
)
const getInputChangeHandler = useCallback(
@@ -15,9 +15,8 @@ export const useDataGridCellSnapshot = <
matrix,
form,
}: UseDataGridCellSnapshotOptions<TData, TFieldValues>) => {
const [snapshot, setSnapshot] = useState<DataGridCellSnapshot<TFieldValues> | null>(
null
)
const [snapshot, setSnapshot] =
useState<DataGridCellSnapshot<TFieldValues> | null>(null)
const { getValues, setValue } = form
@@ -38,7 +37,18 @@ export const useDataGridCellSnapshot = <
const value = getValues(field as Path<TFieldValues>)
setSnapshot({ field, value })
setSnapshot((curr) => {
/**
* If there already exists a snapshot for this field, we don't want to create a new one.
* A case where this happens is when the user presses the space key on a field. In that case
* we create a snapshot of the value before its destroyed by the space key.
*/
if (curr?.field === field) {
return curr
}
return { field, value }
})
},
[getValues, matrix]
)
@@ -123,16 +123,16 @@ export const useDataGridCell = <TData, TValue>({
const validateKeyStroke = useCallback(
(key: string) => {
if (type === "number") {
return numberCharacterRegex.test(key)
switch (type) {
case "togglable-number":
case "number":
return numberCharacterRegex.test(key)
case "text":
return textCharacterRegex.test(key)
default:
// KeyboardEvents should not be forwareded to other types of cells
return false
}
if (type === "text") {
return textCharacterRegex.test(key)
}
// KeyboardEvents should not be forwareded to other types of cells
return false
},
[type]
)
@@ -45,7 +45,14 @@ export const useDataGridClipboardEvents = <
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
const values = getSelectionValues(fields)
const text = values.map((value) => `${value}` ?? "").join("\t")
const text = values
.map((value) => {
if (typeof value === "object" && value !== null) {
return JSON.stringify(value)
}
return `${value}` ?? ""
})
.join("\t")
e.clipboardData?.setData("text/plain", text)
},
@@ -0,0 +1,18 @@
import { useWatch } from "react-hook-form"
import { useDataGridContext } from "../context"
interface UseDataGridDuplicateCellOptions {
duplicateOf: string
}
export const useDataGridDuplicateCell = ({
duplicateOf,
}: UseDataGridDuplicateCellOptions) => {
const { control } = useDataGridContext()
const watchedValue = useWatch({ control, name: duplicateOf })
return {
watchedValue,
}
}
@@ -1,8 +1,14 @@
import get from "lodash/get"
import set from "lodash/set"
import { useCallback } from "react"
import { FieldValues, Path, PathValue, UseFormReturn } from "react-hook-form"
import { DataGridMatrix } from "../models"
import { DataGridColumnType, DataGridCoordinates } from "../types"
import {
DataGridColumnType,
DataGridCoordinates,
DataGridToggleableNumber,
} from "../types"
type UseDataGridFormHandlersOptions<TData, TFieldValues extends FieldValues> = {
matrix: DataGridMatrix<TData, TFieldValues>
@@ -12,13 +18,13 @@ type UseDataGridFormHandlersOptions<TData, TFieldValues extends FieldValues> = {
export const useDataGridFormHandlers = <
TData,
TFieldValues extends FieldValues,
TFieldValues extends FieldValues
>({
matrix,
form,
anchor,
}: UseDataGridFormHandlersOptions<TData, TFieldValues>) => {
const { getValues, setValue } = form
const { getValues, reset } = form
const getSelectionValues = useCallback(
(fields: string[]): PathValue<TFieldValues, Path<TFieldValues>>[] => {
@@ -26,26 +32,28 @@ export const useDataGridFormHandlers = <
return []
}
const allValues = getValues()
return fields.map((field) => {
return getValues(field as Path<TFieldValues>)
})
return field.split(".").reduce((obj, key) => obj?.[key], allValues)
}) as PathValue<TFieldValues, Path<TFieldValues>>[]
},
[getValues]
)
const setSelectionValues = useCallback(
async (fields: string[], values: string[]) => {
async (fields: string[], values: string[], isHistory?: boolean) => {
if (!fields.length || !anchor) {
return
}
const type = matrix.getCellType(anchor)
if (!type) {
return
}
const convertedValues = convertArrayToPrimitive(values, type)
const currentValues = getValues()
fields.forEach((field, index) => {
if (!field) {
@@ -53,18 +61,18 @@ export const useDataGridFormHandlers = <
}
const valueIndex = index % values.length
const value = convertedValues[valueIndex] as PathValue<
TFieldValues,
Path<TFieldValues>
>
const newValue = convertedValues[valueIndex]
setValue(field as Path<TFieldValues>, value, {
shouldDirty: true,
shouldTouch: true,
})
setValue(currentValues, field, newValue, type, isHistory)
})
reset(currentValues, {
keepDirty: true,
keepTouched: true,
keepDefaultValues: true,
})
},
[matrix, anchor, setValue]
[matrix, anchor, getValues, reset]
)
return {
@@ -113,13 +121,97 @@ function covertToString(value: any): string {
return String(value)
}
function convertToggleableNumber(value: any): {
quantity: number
checked: boolean
disabledToggle: boolean
} {
let obj = value
if (typeof obj === "string") {
try {
obj = JSON.parse(obj)
} catch (error) {
throw new Error(`String "${value}" cannot be converted to object.`)
}
}
return obj
}
function setValue<
T extends DataGridToggleableNumber = DataGridToggleableNumber
>(
currentValues: any,
field: string,
newValue: T,
type: string,
isHistory?: boolean
) {
if (type !== "togglable-number") {
set(currentValues, field, newValue)
return
}
setValueToggleableNumber(currentValues, field, newValue, isHistory)
}
function setValueToggleableNumber(
currentValues: any,
field: string,
newValue: DataGridToggleableNumber,
isHistory?: boolean
) {
const currentValue = get(currentValues, field)
const { disabledToggle } = currentValue
const normalizeQuantity = (value: number | string | null | undefined) => {
if (disabledToggle && value === "") {
return 0
}
return value
}
const determineChecked = (quantity: number | string | null | undefined) => {
if (disabledToggle) {
return true
}
return quantity !== "" && quantity != null
}
const quantity = normalizeQuantity(newValue.quantity)
const checked = isHistory
? disabledToggle
? true
: newValue.checked
: determineChecked(quantity)
set(currentValues, field, {
...currentValue,
quantity,
checked,
})
}
export function convertArrayToPrimitive(
values: any[],
type: DataGridColumnType
): any[] {
switch (type) {
case "number":
return values.map((v) => (v === "" ? v : convertToNumber(v)))
return values.map((v) => {
if (v === "") {
return v
}
if (v == null) {
return ""
}
return convertToNumber(v)
})
case "togglable-number":
return values.map(convertToggleableNumber)
case "boolean":
return values.map(convertToBoolean)
case "text":
@@ -1,4 +1,4 @@
import { useCallback } from "react"
import React, { useCallback } from "react"
import type {
FieldValues,
Path,
@@ -39,6 +39,7 @@ type UseDataGridKeydownEventOptions<TData, TFieldValues extends FieldValues> = {
) => PathValue<TFieldValues, Path<TFieldValues>>[]
setSelectionValues: (fields: string[], values: string[]) => void
restoreSnapshot: () => void
createSnapshot: (coords: DataGridCoordinates) => void
}
const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]
@@ -67,6 +68,7 @@ export const useDataGridKeydownEvent = <
getSelectionValues,
setSelectionValues,
restoreSnapshot,
createSnapshot,
}: UseDataGridKeydownEventOptions<TData, TFieldValues>) => {
const handleKeyboardNavigation = useCallback(
(e: KeyboardEvent) => {
@@ -218,6 +220,8 @@ export const useDataGridKeydownEvent = <
return
}
createSnapshot(anchor)
const current = getValues(field as Path<TFieldValues>)
const next = ""
@@ -236,7 +240,46 @@ export const useDataGridKeydownEvent = <
input.focus()
},
[matrix, queryTool, getValues, execute, setValue]
[matrix, queryTool, getValues, execute, setValue, createSnapshot]
)
const handleSpaceKeyTogglableNumber = useCallback(
(anchor: DataGridCoordinates) => {
const field = matrix.getCellField(anchor)
const input = queryTool?.getInput(anchor)
if (!field || !input) {
return
}
createSnapshot(anchor)
const current = getValues(field as Path<TFieldValues>)
let checked = current.checked
// If the toggle is not disabled, then we want to uncheck the toggle.
if (!current.disabledToggle) {
checked = false
}
const next = { ...current, quantity: "", checked }
const command = new DataGridUpdateCommand({
next,
prev: current,
setter: (value) => {
setValue(field as Path<TFieldValues>, value, {
shouldDirty: true,
shouldTouch: true,
})
},
})
execute(command)
input.focus()
},
[matrix, queryTool, getValues, execute, setValue, createSnapshot]
)
const handleSpaceKey = useCallback(
@@ -257,6 +300,9 @@ export const useDataGridKeydownEvent = <
case "boolean":
handleSpaceKeyBoolean(anchor)
break
case "togglable-number":
handleSpaceKeyTogglableNumber(anchor)
break
case "number":
case "text":
handleSpaceKeyTextOrNumber(anchor)
@@ -269,6 +315,7 @@ export const useDataGridKeydownEvent = <
matrix,
handleSpaceKeyBoolean,
handleSpaceKeyTextOrNumber,
handleSpaceKeyTogglableNumber,
]
)
@@ -390,6 +437,7 @@ export const useDataGridKeydownEvent = <
const type = matrix.getCellType(anchor)
switch (type) {
case "togglable-number":
case "text":
case "number":
handleEnterKeyTextOrNumber(e, anchor)
@@ -403,6 +451,29 @@ export const useDataGridKeydownEvent = <
[anchor, matrix, handleEnterKeyTextOrNumber, handleEnterKeyBoolean]
)
const handleDeleteKeyTogglableNumber = useCallback(
(anchor: DataGridCoordinates, rangeEnd: DataGridCoordinates) => {
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
const prev = getSelectionValues(fields)
const next = prev.map((value) => ({
...value,
quantity: "",
checked: value.disableToggle ? value.checked : false,
}))
const command = new DataGridBulkUpdateCommand({
fields,
next,
prev,
setter: setSelectionValues,
})
execute(command)
},
[matrix, getSelectionValues, setSelectionValues, execute]
)
const handleDeleteKeyTextOrNumber = useCallback(
(anchor: DataGridCoordinates, rangeEnd: DataGridCoordinates) => {
const fields = matrix.getFieldsInSelection(anchor, rangeEnd)
@@ -461,6 +532,9 @@ export const useDataGridKeydownEvent = <
case "boolean":
handleDeleteKeyBoolean(anchor, rangeEnd)
break
case "togglable-number":
handleDeleteKeyTogglableNumber(anchor, rangeEnd)
break
}
},
[
@@ -470,6 +544,7 @@ export const useDataGridKeydownEvent = <
matrix,
handleDeleteKeyTextOrNumber,
handleDeleteKeyBoolean,
handleDeleteKeyTogglableNumber,
]
)
@@ -518,7 +593,7 @@ export const useDataGridKeydownEvent = <
break
}
},
[anchor, isEditing, setTrapActive, containerRef]
[isEditing, setTrapActive, containerRef]
)
const handleKeyDownEvent = useCallback(
@@ -4,7 +4,7 @@ export type DataGridBulkUpdateCommandArgs = {
fields: string[]
next: any[]
prev: any[]
setter: (fields: string[], values: any[]) => void
setter: (fields: string[], values: any[], isHistory?: boolean) => void
}
export class DataGridBulkUpdateCommand implements Command {
@@ -13,7 +13,11 @@ export class DataGridBulkUpdateCommand implements Command {
private _prev: any[]
private _next: any[]
private _setter: (fields: string[], any: string[]) => void
private _setter: (
fields: string[],
values: any[],
isHistory?: boolean
) => void
constructor({ fields, prev, next, setter }: DataGridBulkUpdateCommandArgs) {
this._fields = fields
@@ -22,13 +26,13 @@ export class DataGridBulkUpdateCommand implements Command {
this._setter = setter
}
execute(): void {
this._setter(this._fields, this._next)
execute(redo = false): void {
this._setter(this._fields, this._next, redo)
}
undo(): void {
this._setter(this._fields, this._prev)
this._setter(this._fields, this._prev, true)
}
redo(): void {
this.execute()
this.execute(true)
}
}
@@ -1,13 +1,25 @@
import { ColumnDef, Row } from "@tanstack/react-table"
import { FieldValues } from "react-hook-form"
import { DataGridColumnType, DataGridCoordinates, Grid, GridCell, InternalColumnMeta } from "../types"
import {
DataGridColumnType,
DataGridCoordinates,
Grid,
GridCell,
InternalColumnMeta,
} from "../types"
export class DataGridMatrix<TData, TFieldValues extends FieldValues> {
private multiColumnSelection: boolean
private cells: Grid<TFieldValues>
public rowAccessors: (string | null)[] = []
public columnAccessors: (string | null)[] = []
constructor(data: Row<TData>[], columns: ColumnDef<TData>[]) {
constructor(
data: Row<TData>[],
columns: ColumnDef<TData>[],
multiColumnSelection: boolean = false
) {
this.multiColumnSelection = multiColumnSelection
this.cells = this._populateCells(data, columns)
this.rowAccessors = this._computeRowAccessors()
@@ -64,17 +76,26 @@ export class DataGridMatrix<TData, TFieldValues extends FieldValues> {
return keys
}
if (start.col !== end.col) {
throw new Error("Selection must be in the same column")
if (!this.multiColumnSelection && start.col !== end.col) {
throw new Error(
"Selection must be in the same column when multiColumnSelection is disabled"
)
}
const startRow = Math.min(start.row, end.row)
const endRow = Math.max(start.row, end.row)
const col = start.col
const startCol = this.multiColumnSelection
? Math.min(start.col, end.col)
: start.col
const endCol = this.multiColumnSelection
? Math.max(start.col, end.col)
: start.col
for (let row = startRow; row <= endRow; row++) {
if (this._isValidPosition(row, col) && this.cells[row][col] !== null) {
keys.push(this.cells[row][col]?.field as string)
for (let col = startCol; col <= endCol; col++) {
if (this._isValidPosition(row, col) && this.cells[row][col] !== null) {
keys.push(this.cells[row][col]?.field as string)
}
}
}
@@ -106,15 +127,27 @@ export class DataGridMatrix<TData, TFieldValues extends FieldValues> {
return false
}
if (start.col !== end.col) {
throw new Error("Selection must be in the same column")
if (!this.multiColumnSelection && start.col !== end.col) {
throw new Error(
"Selection must be in the same column when multiColumnSelection is disabled"
)
}
const startRow = Math.min(start.row, end.row)
const endRow = Math.max(start.row, end.row)
const col = start.col
const startCol = this.multiColumnSelection
? Math.min(start.col, end.col)
: start.col
const endCol = this.multiColumnSelection
? Math.max(start.col, end.col)
: start.col
return cell.col === col && cell.row >= startRow && cell.row <= endRow
return (
cell.row >= startRow &&
cell.row <= endRow &&
cell.col >= startCol &&
cell.col <= endCol
)
}
toggleColumn(col: number, enabled: boolean) {
@@ -385,4 +418,4 @@ export class DataGridMatrix<TData, TFieldValues extends FieldValues> {
return cells
}
}
}
@@ -14,7 +14,11 @@ import {
PathValue,
} from "react-hook-form"
export type DataGridColumnType = "text" | "number" | "boolean"
export type DataGridColumnType =
| "text"
| "number"
| "boolean"
| "togglable-number"
export type DataGridCoordinates = {
row: number
@@ -100,7 +104,7 @@ export interface DataGridCellContainerProps extends PropsWithChildren<{}> {
}
export type DataGridCellSnapshot<
TFieldValues extends FieldValues = FieldValues
TFieldValues extends FieldValues = FieldValues,
> = {
field: string
value: PathValue<TFieldValues, Path<TFieldValues>>
@@ -160,3 +164,9 @@ export type GridColumnOption = {
checked: boolean
disabled: boolean
}
export type DataGridToggleableNumber = {
quantity: number | string
checked: boolean
disabledToggle: boolean
}
@@ -1,6 +1,6 @@
import { XMarkMini } from "@medusajs/icons"
import { Badge, clx } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion"
import { AnimatePresence, motion } from "motion/react"
import {
FocusEvent,
KeyboardEvent,
@@ -2,28 +2,47 @@ import * as Dialog from "@radix-ui/react-dialog"
import { SidebarLeft, TriangleRightMini, XMark } from "@medusajs/icons"
import { IconButton, clx } from "@medusajs/ui"
import { PropsWithChildren, ReactNode } from "react"
import { AnimatePresence } from "motion/react"
import { PropsWithChildren, ReactNode, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link, Outlet, UIMatch, useMatches } from "react-router-dom"
import {
Link,
Outlet,
UIMatch,
useMatches,
useNavigation,
} from "react-router-dom"
import { KeybindProvider } from "../../../providers/keybind-provider"
import { useGlobalShortcuts } from "../../../providers/keybind-provider/hooks"
import { useSidebar } from "../../../providers/sidebar-provider"
import { ProgressBar } from "../../common/progress-bar"
import { Notifications } from "../notifications"
export const Shell = ({ children }: PropsWithChildren) => {
const globalShortcuts = useGlobalShortcuts()
const navigation = useNavigation()
const loading = navigation.state === "loading"
return (
<KeybindProvider shortcuts={globalShortcuts}>
<div className="flex h-screen flex-col items-start overflow-hidden lg:flex-row">
<div className="relative flex h-screen flex-col items-start overflow-hidden lg:flex-row">
<NavigationBar loading={loading} />
<div>
<MobileSidebarContainer>{children}</MobileSidebarContainer>
<DesktopSidebarContainer>{children}</DesktopSidebarContainer>
</div>
<div className="flex h-screen w-full flex-col overflow-auto">
<Topbar />
<main className="flex h-full w-full flex-col items-center overflow-y-auto">
<main
className={clx(
"flex h-full w-full flex-col items-center overflow-y-auto transition-opacity delay-200 duration-200",
{
"opacity-25": loading,
}
)}
>
<Gutter>
<Outlet />
</Gutter>
@@ -34,6 +53,36 @@ export const Shell = ({ children }: PropsWithChildren) => {
)
}
const NavigationBar = ({ loading }: { loading: boolean }) => {
const [showBar, setShowBar] = useState(false)
/**
* If the loading state is true, we want to show the bar after a short delay.
* The delay is used to prevent the bar from flashing on quick navigations.
*/
useEffect(() => {
let timeout: ReturnType<typeof setTimeout>
if (loading) {
timeout = setTimeout(() => {
setShowBar(true)
}, 200)
} else {
setShowBar(false)
}
return () => {
clearTimeout(timeout)
}
}, [loading])
return (
<div className="fixed inset-x-0 top-0 z-50 h-1">
<AnimatePresence>{showBar ? <ProgressBar /> : null}</AnimatePresence>
</div>
)
}
const Gutter = ({ children }: PropsWithChildren) => {
return (
<div className="flex w-full max-w-[1600px] flex-col gap-y-2 p-3">
@@ -1,3 +1,4 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import {
QueryKey,
@@ -6,11 +7,11 @@ import {
useMutation,
useQuery,
} from "@tanstack/react-query"
import { FetchError } from "@medusajs/js-sdk"
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory } from "../../lib/query-key-factory"
import { variantsQueryKeys } from "./products"
const INVENTORY_ITEMS_QUERY_KEY = "inventory_items" as const
export const inventoryItemsQueryKeys = queryKeysFactory(
@@ -23,7 +24,7 @@ export const inventoryItemLevelsQueryKeys = queryKeysFactory(
)
export const useInventoryItems = (
query?: Record<string, any>,
query?: HttpTypes.AdminInventoryItemParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminInventoryItemListResponse,
@@ -136,7 +137,7 @@ export const useDeleteInventoryItemLevel = (
inventoryItemId: string,
locationId: string,
options?: UseMutationOptions<
HttpTypes.AdminInventoryItemDeleteResponse,
HttpTypes.AdminInventoryLevelDeleteResponse,
FetchError,
void
>
@@ -210,17 +211,20 @@ export const useUpdateInventoryLevel = (
})
}
export const useBatchUpdateInventoryLevels = (
export const useBatchInventoryItemLocationLevels = (
inventoryItemId: string,
options?: UseMutationOptions<
HttpTypes.AdminInventoryItemResponse,
HttpTypes.AdminBatchInventoryItemLocationLevelsResponse,
FetchError,
HttpTypes.AdminBatchUpdateInventoryLevelLocation
HttpTypes.AdminBatchInventoryItemLocationLevels
>
) => {
return useMutation({
mutationFn: (payload: HttpTypes.AdminBatchUpdateInventoryLevelLocation) =>
sdk.admin.inventoryItem.batchUpdateLevels(inventoryItemId, payload),
mutationFn: (payload) =>
sdk.admin.inventoryItem.batchInventoryItemLocationLevels(
inventoryItemId,
payload
),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.lists(),
@@ -236,3 +240,26 @@ export const useBatchUpdateInventoryLevels = (
...options,
})
}
export const useBatchInventoryItemsLocationLevels = (
options?: UseMutationOptions<
HttpTypes.AdminBatchInventoryItemsLocationLevelsResponse,
FetchError,
HttpTypes.AdminBatchInventoryItemsLocationLevels
>
) => {
return useMutation({
mutationFn: (payload) =>
sdk.admin.inventoryItem.batchInventoryItemsLocationLevels(payload),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({
queryKey: inventoryItemsQueryKeys.all,
})
queryClient.invalidateQueries({
queryKey: variantsQueryKeys.lists(),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -110,9 +110,14 @@ export const useProductVariant = (
export const useProductVariants = (
productId: string,
query?: Record<string, any>,
query?: HttpTypes.AdminProductVariantParams,
options?: Omit<
UseQueryOptions<any, FetchError, any, QueryKey>,
UseQueryOptions<
HttpTypes.AdminProductVariantListResponse,
FetchError,
HttpTypes.AdminProductVariantListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
@@ -2649,6 +2649,35 @@
],
"additionalProperties": false
},
"stock": {
"type": "object",
"properties": {
"heading": {
"type": "string"
},
"description": {
"type": "string"
},
"loading": {
"type": "string"
},
"tooltips": {
"type": "object",
"properties": {
"alreadyManaged": {
"type": "string"
},
"alreadyManagedWithSku": {
"type": "string"
}
},
"required": ["alreadyManaged", "alreadyManagedWithSku"],
"additionalProperties": false
}
},
"required": ["heading", "description", "loading", "tooltips"],
"additionalProperties": false
},
"toasts": {
"type": "object",
"properties": {
@@ -2721,6 +2750,7 @@
"variant",
"options",
"organization",
"stock",
"toasts"
],
"additionalProperties": false
@@ -3307,6 +3337,46 @@
"updateItem"
],
"additionalProperties": false
},
"stock": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"action": {
"type": "string"
},
"placeholder": {
"type": "string"
},
"disablePrompt_one": {
"type": "string"
},
"disablePrompt_other": {
"type": "string"
},
"disabledToggleTooltip": {
"type": "string"
},
"successToast": {
"type": "string"
}
},
"required": [
"title",
"description",
"action",
"placeholder",
"disablePrompt_one",
"disablePrompt_other",
"disabledToggleTooltip",
"successToast"
],
"additionalProperties": false
}
},
"required": [
@@ -3322,7 +3392,8 @@
"create",
"reservation",
"adjustInventory",
"toast"
"toast",
"stock"
],
"additionalProperties": false
},
@@ -645,6 +645,15 @@
}
}
},
"stock": {
"heading": "Manage product stock levels and locations",
"description": "Update the stocked inventory levels for all of the product's variants.",
"loading": "Hang on this may take a moment...",
"tooltips": {
"alreadyManaged": "This inventory item is already editable under {{title}}.",
"alreadyManagedWithSku": "This inventory item is already editable under {{title}} ({{sku}})."
}
},
"toasts": {
"delete": {
"success": {
@@ -799,6 +808,16 @@
"updateLocations": "Locations updated successfully.",
"updateLevel": "Inventory level updated successfully.",
"updateItem": "Inventory item updated successfully."
},
"stock": {
"title": "Update inventory levels",
"description": "Update the stocked inventory levels for the selected inventory items.",
"action": "Edit stock levels",
"placeholder": "Not enabled",
"disablePrompt_one": "You are about to disable {{count}} location level. This action cannot be undone.",
"disablePrompt_other": "You are about to disable {{count}} location levels. This action cannot be undone.",
"disabledToggleTooltip": "Cannot disable: clear incoming and/or reserved quantity before disabling.",
"successToast": "Inventory levels updated successfully."
}
},
"giftCards": {
@@ -129,6 +129,11 @@ export const RouteMap: RouteObject[] = [
"../../routes/products/product-create-variant"
),
},
{
path: "stock",
lazy: () =>
import("../../routes/products/product-stock"),
},
{
path: "metadata/edit",
lazy: () =>
@@ -764,6 +769,11 @@ export const RouteMap: RouteObject[] = [
lazy: () =>
import("../../routes/inventory/inventory-create"),
},
{
path: "stock",
lazy: () =>
import("../../routes/inventory/inventory-stock"),
},
],
},
{
@@ -6,8 +6,8 @@ import { useDashboardExtension } from "../../../extensions"
import { useCustomer } from "../../../hooks/api/customers"
import { CustomerGeneralSection } from "./components/customer-general-section"
import { CustomerGroupSection } from "./components/customer-group-section"
import { customerLoader } from "./loader"
import { CustomerOrderSection } from "./components/customer-order-section"
import { customerLoader } from "./loader"
export const CustomerDetail = () => {
const { id } = useParams()
@@ -0,0 +1 @@
export const INVENTORY_ITEM_IDS_KEY = "inventory_item_ids"
@@ -1,8 +1,8 @@
import { Button, Container, Heading } from "@medusajs/ui"
import { ItemLocationListTable } from "./location-levels-table/location-list-table"
import { Link } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { HttpTypes } from "@medusajs/types"
import { Button, Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { ItemLocationListTable } from "./location-levels-table/location-list-table"
type InventoryItemLocationLevelsSectionProps = {
inventoryItem: HttpTypes.AdminInventoryItemResponse["inventory_item"]
@@ -13,7 +13,7 @@ export const InventoryItemLocationLevelsSection = ({
const { t } = useTranslation()
return (
<Container className="p-0">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("inventory.locationLevels")}</Heading>
<Button size="small" variant="secondary" asChild>
@@ -1,8 +1,8 @@
import { HttpTypes } from "@medusajs/types"
import { Button, Container, Heading } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { ReservationItemTable } from "./reservations-table/reservation-list-table"
import { useTranslation } from "react-i18next"
import { HttpTypes } from "@medusajs/types"
type InventoryItemLocationLevelsSectionProps = {
inventoryItem: HttpTypes.AdminInventoryItemResponse["inventory_item"]
@@ -13,7 +13,7 @@ export const InventoryItemReservationsSection = ({
const { t } = useTranslation()
return (
<Container className="p-0">
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{t("reservations.domain")}</Heading>
<Button size="small" variant="secondary" asChild>
@@ -1,10 +1,10 @@
import { InventoryTypes, 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"
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
import { LocationActions } from "./location-actions"
/**
* Adds missing properties to the InventoryLevelDTO type.
@@ -7,7 +7,7 @@ import { useFieldArray, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { RouteDrawer, useRouteModal } from "../../../../../../components/modals"
import { useBatchUpdateInventoryLevels } from "../../../../../../hooks/api/inventory"
import { useBatchInventoryItemLocationLevels } from "../../../../../../hooks/api/inventory"
import { useEffect, useMemo } from "react"
import { KeyboundForm } from "../../../../../../components/utilities/keybound-form"
@@ -70,7 +70,7 @@ export const ManageLocationsForm = ({
)
}, [existingLocationLevels, locations])
const { mutateAsync } = useBatchUpdateInventoryLevels(item.id)
const { mutateAsync } = useBatchInventoryItemLocationLevels(item.id)
const handleSubmit = form.handleSubmit(async ({ locations }) => {
// Changes in selected locations
@@ -1,11 +1,14 @@
import { InventoryTypes } from "@medusajs/types"
import { Button, Container, Heading, Text } from "@medusajs/ui"
import { RowSelectionState } from "@tanstack/react-table"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { Link, useNavigate } from "react-router-dom"
import { DataTable } from "../../../../components/table/data-table"
import { useInventoryItems } from "../../../../hooks/api/inventory"
import { useDataTable } from "../../../../hooks/use-data-table"
import { INVENTORY_ITEM_IDS_KEY } from "../../common/constants"
import { useInventoryTableColumns } from "./use-inventory-table-columns"
import { useInventoryTableFilters } from "./use-inventory-table-filters"
import { useInventoryTableQuery } from "./use-inventory-table-query"
@@ -14,6 +17,9 @@ const PAGE_SIZE = 20
export const InventoryListTable = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [selection, setSelection] = useState<RowSelectionState>({})
const { searchParams, raw } = useInventoryTableQuery({
pageSize: PAGE_SIZE,
@@ -39,6 +45,11 @@ export const InventoryListTable = () => {
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: true,
rowSelection: {
state: selection,
updater: setSelection,
},
})
if (isError) {
@@ -75,6 +86,19 @@ export const InventoryListTable = () => {
{ key: "reserved_quantity", label: t("inventory.reserved") },
]}
navigateTo={(row) => `${row.id}`}
commands={[
{
action: async (selection) => {
navigate(
`stock?${INVENTORY_ITEM_IDS_KEY}=${Object.keys(selection).join(
","
)}`
)
},
label: t("inventory.stock.action"),
shortcut: "i",
},
]}
/>
</Container>
)
@@ -1,10 +1,11 @@
import { InventoryTypes, ProductVariantDTO } from "@medusajs/types"
import { InventoryActions } from "./inventory-actions"
import { PlaceholderCell } from "../../../../components/table/table-cells/common/placeholder-cell"
import { Checkbox } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../../../components/table/table-cells/common/placeholder-cell"
import { InventoryActions } from "./inventory-actions"
/**
* Adds missing properties to the InventoryItemDTO type.
@@ -22,6 +23,34 @@ export const useInventoryTableColumns = () => {
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
columnHelper.accessor("title", {
header: t("fields.title"),
cell: ({ getValue }) => {
@@ -0,0 +1 @@
export * from "./inventory-stock-form"
@@ -0,0 +1,164 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, toast } from "@medusajs/ui"
import { useRef } from "react"
import { DefaultValues, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { DataGrid } from "../../../../../components/data-grid"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useBatchInventoryItemsLocationLevels } from "../../../../../hooks/api"
import { castNumber } from "../../../../../lib/cast-number"
import { useInventoryStockColumns } from "../../hooks/use-inventory-stock-columns"
import {
InventoryItemSchema,
InventoryLocationsSchema,
InventoryStockSchema,
} from "../../schema"
type InventoryStockFormProps = {
items: HttpTypes.AdminInventoryItem[]
locations: HttpTypes.AdminStockLocation[]
}
export const InventoryStockForm = ({
items,
locations,
}: InventoryStockFormProps) => {
const { t } = useTranslation()
const { setCloseOnEscape, handleSuccess } = useRouteModal()
const initialValues = useRef(getDefaultValues(items, locations))
console.log("initialValues", initialValues.current)
const form = useForm<InventoryStockSchema>({
defaultValues: getDefaultValues(items, locations),
resolver: zodResolver(InventoryStockSchema),
})
const columns = useInventoryStockColumns(locations)
const { mutateAsync, isPending } = useBatchInventoryItemsLocationLevels()
const onSubmit = form.handleSubmit(async (data) => {
const payload: HttpTypes.AdminBatchInventoryItemsLocationLevels = {
create: [],
update: [],
delete: [],
force: true,
}
for (const [inventory_item_id, item] of Object.entries(
data.inventory_items
)) {
for (const [location_id, level] of Object.entries(item.locations)) {
if (level.id) {
const wasChecked =
initialValues.current?.inventory_items?.[inventory_item_id]
?.locations?.[location_id]?.checked
if (wasChecked && !level.checked) {
payload.delete.push(level.id)
} else {
const newQuantity =
level.quantity !== "" ? castNumber(level.quantity) : 0
const originalQuantity =
initialValues.current?.inventory_items?.[inventory_item_id]
?.locations?.[location_id]?.quantity
if (newQuantity !== originalQuantity) {
payload.update.push({
id: level.id,
inventory_item_id,
location_id,
stocked_quantity: newQuantity,
})
}
}
}
if (!level.id && level.quantity !== "") {
payload.create.push({
inventory_item_id,
location_id,
stocked_quantity: castNumber(level.quantity),
})
}
}
}
await mutateAsync(payload, {
onSuccess: () => {
toast.success(t("inventory.stock.successToast"))
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
})
})
return (
<RouteFocusModal.Form form={form}>
<KeyboundForm onSubmit={onSubmit} className="flex size-full flex-col">
<RouteFocusModal.Header />
<RouteFocusModal.Body className="size-full flex-1 overflow-y-auto">
<DataGrid
columns={columns}
data={items}
state={form}
onEditingChange={(editing) => {
setCloseOnEscape(!editing)
}}
/>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small" type="button">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button type="submit" size="small" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Footer>
</KeyboundForm>
</RouteFocusModal.Form>
)
}
function getDefaultValues(
items: HttpTypes.AdminInventoryItem[],
locations: HttpTypes.AdminStockLocation[]
): DefaultValues<InventoryStockSchema> {
return {
inventory_items: items.reduce((acc, item) => {
const locationsMap = locations.reduce((locationAcc, location) => {
const level = item.location_levels?.find(
(level) => level.location_id === location.id
)
locationAcc[location.id] = {
id: level?.id,
quantity:
typeof level?.stocked_quantity === "number"
? level?.stocked_quantity
: "",
checked: !!level,
disabledToggle:
(level?.incoming_quantity || 0) > 0 ||
(level?.reserved_quantity || 0) > 0,
}
return locationAcc
}, {} as InventoryLocationsSchema)
acc[item.id] = { locations: locationsMap }
return acc
}, {} as Record<string, InventoryItemSchema>),
}
}
@@ -0,0 +1,76 @@
import { HttpTypes } from "@medusajs/types"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { createDataGridHelper } from "../../../../components/data-grid"
import { DataGridReadOnlyCell } from "../../../../components/data-grid/components"
import { DataGridTogglableNumberCell } from "../../../../components/data-grid/components/data-grid-toggleable-number-cell"
import { InventoryStockSchema } from "../schema"
const helper = createDataGridHelper<
HttpTypes.AdminInventoryItem,
InventoryStockSchema
>()
export const useInventoryStockColumns = (
locations: HttpTypes.AdminStockLocation[] = []
) => {
const { t } = useTranslation()
return useMemo(
() => [
helper.column({
id: "title",
name: "Title",
header: "Title",
cell: (context) => {
const item = context.row.original
return (
<DataGridReadOnlyCell context={context} color="normal">
<span title={item.title || undefined}>{item.title || "-"}</span>
</DataGridReadOnlyCell>
)
},
disableHiding: true,
}),
helper.column({
id: "sku",
name: "SKU",
header: "SKU",
cell: (context) => {
const item = context.row.original
return (
<DataGridReadOnlyCell context={context} color="normal">
<span title={item.sku || undefined}>{item.sku || "-"}</span>
</DataGridReadOnlyCell>
)
},
disableHiding: true,
}),
...locations.map((location) =>
helper.column({
id: `location_${location.id}`,
name: location.name,
header: location.name,
field: (context) => {
const item = context.row.original
return `inventory_items.${item.id}.locations.${location.id}` as const
},
type: "togglable-number",
cell: (context) => {
return (
<DataGridTogglableNumberCell
context={context}
disabledToggleTooltip={t(
"inventory.stock.disabledToggleTooltip"
)}
/>
)
},
})
),
],
[locations, t]
)
}
@@ -0,0 +1 @@
export { InventoryStock as Component } from "./inventory-stock"
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next"
import { useSearchParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/modals"
import { useInventoryItems, useStockLocations } from "../../../hooks/api"
import { INVENTORY_ITEM_IDS_KEY } from "../common/constants"
import { InventoryStockForm } from "./components/inventory-stock-form"
export const InventoryStock = () => {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const inventoryItemIds =
searchParams.get(INVENTORY_ITEM_IDS_KEY)?.split(",") || undefined
const { inventory_items, isPending, isError, error } = useInventoryItems({
id: inventoryItemIds,
})
const {
stock_locations,
isPending: isPendingStockLocations,
isError: isErrorStockLocations,
error: errorStockLocations,
} = useStockLocations({
limit: 9999,
fields: "id,name",
})
const ready =
!isPending &&
!!inventory_items &&
!isPendingStockLocations &&
!!stock_locations
if (isError) {
throw error
}
if (isErrorStockLocations) {
throw errorStockLocations
}
return (
<RouteFocusModal>
<RouteFocusModal.Title asChild>
<span className="sr-only">{t("inventory.stock.title")}</span>
</RouteFocusModal.Title>
<RouteFocusModal.Description asChild>
<span className="sr-only">{t("inventory.stock.description")}</span>
</RouteFocusModal.Description>
{ready && (
<InventoryStockForm
items={inventory_items}
locations={stock_locations}
/>
)}
</RouteFocusModal>
)
}
@@ -0,0 +1,22 @@
import { z } from "zod"
const LocationQuantitySchema = z.object({
id: z.string().optional(),
quantity: z.union([z.number(), z.string()]),
checked: z.boolean(),
disabledToggle: z.boolean(),
})
const InventoryLocationsSchema = z.record(LocationQuantitySchema)
const InventoryItemSchema = z.object({
locations: InventoryLocationsSchema,
})
export const InventoryStockSchema = z.object({
inventory_items: z.record(InventoryItemSchema),
})
export type InventoryLocationsSchema = z.infer<typeof InventoryLocationsSchema>
export type InventoryItemSchema = z.infer<typeof InventoryItemSchema>
export type InventoryStockSchema = z.infer<typeof InventoryStockSchema>
@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Alert, Button, Heading, Hint, Input, Text, toast } from "@medusajs/ui"
import { AnimatePresence, motion } from "framer-motion"
import i18n from "i18next"
import { AnimatePresence, motion } from "motion/react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
@@ -130,7 +130,7 @@ const OuterComponent = ({
return (
<div
className="absolute inset-y-0 z-[2] flex w-fit items-center justify-center"
className="absolute inset-y-0 z-[3] flex w-fit items-center justify-center"
style={{
left: symbolWidth ? `${symbolWidth + 16 + 4}px` : undefined,
}}
@@ -41,7 +41,7 @@ export const usePriceListGridColumns = ({
return (
<DataGrid.ReadonlyCell context={context}>
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<Thumbnail src={entity.thumbnail} />
<Thumbnail src={entity.thumbnail} size="small" />
<span className="truncate">{entity.title}</span>
</div>
</DataGrid.ReadonlyCell>
@@ -49,7 +49,7 @@ export const usePriceListGridColumns = ({
}
return (
<DataGrid.ReadonlyCell context={context}>
<DataGrid.ReadonlyCell context={context} color="normal">
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
<span className="truncate">{entity.title}</span>
</div>
@@ -0,0 +1 @@
export const PRODUCT_VARIANT_IDS_KEY = "product_variant_ids"
@@ -1,13 +1,17 @@
import { PencilSquare, Plus } from "@medusajs/icons"
import { Buildings, PencilSquare, Plus } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Container, Heading } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { RowSelectionState } from "@tanstack/react-table"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useProductVariants } from "../../../../../hooks/api/products"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { PRODUCT_VARIANT_IDS_KEY } from "../../../common/constants"
import { useProductVariantTableColumns } from "./use-variant-table-columns"
import { useProductVariantTableFilters } from "./use-variant-table-filters"
import { useProductVariantTableQuery } from "./use-variant-table-query"
@@ -22,6 +26,7 @@ export const ProductVariantSection = ({
product,
}: ProductVariantSectionProps) => {
const { t } = useTranslation()
const navigate = useNavigate()
const { searchParams, raw } = useProductVariantTableQuery({
pageSize: PAGE_SIZE,
@@ -37,6 +42,8 @@ export const ProductVariantSection = ({
}
)
const [selection, setSelection] = useState<RowSelectionState>({})
const filters = useProductVariantTableFilters()
const columns = useProductVariantTableColumns(product)
@@ -47,6 +54,11 @@ export const ProductVariantSection = ({
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
enableRowSelection: true,
rowSelection: {
state: selection,
updater: setSelection,
},
meta: {
product,
},
@@ -74,6 +86,11 @@ export const ProductVariantSection = ({
to: `prices`,
icon: <PencilSquare />,
},
{
label: t("inventory.stock.action"),
to: `stock`,
icon: <Buildings />,
},
],
},
]}
@@ -97,6 +114,19 @@ export const ProductVariantSection = ({
pagination
search
queryObject={raw}
commands={[
{
action: async (selection) => {
navigate(
`stock?${PRODUCT_VARIANT_IDS_KEY}=${Object.keys(selection).join(
","
)}`
)
},
label: t("inventory.stock.action"),
shortcut: "i",
},
]}
/>
</Container>
)
@@ -1,6 +1,6 @@
import { Buildings, Component, PencilSquare, Trash } from "@medusajs/icons"
import { HttpTypes, InventoryItemDTO } from "@medusajs/types"
import { Badge, clx, usePrompt } from "@medusajs/ui"
import { Badge, Checkbox, clx, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
@@ -73,11 +73,6 @@ const VariantActions = ({
to: `edit-variant?variant_id=${variant.id}`,
icon: <PencilSquare />,
},
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
hasInventoryItem
? {
label: t("products.variant.inventory.actions.inventoryItems"),
@@ -94,6 +89,15 @@ const VariantActions = ({
: false,
].filter(Boolean) as Action[],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
@@ -145,6 +149,34 @@ export const useProductVariantTableColumns = (
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
columnHelper.accessor("title", {
header: () => (
<div className="flex h-full w-full items-center">
@@ -0,0 +1 @@
export * from "./product-stock-form"
@@ -0,0 +1,220 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { HttpTypes } from "@medusajs/types"
import { Button, toast, usePrompt } from "@medusajs/ui"
import { useEffect, useMemo, useRef, useState } from "react"
import { DefaultValues, useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { DataGrid } from "../../../../../components/data-grid"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/modals"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { useBatchInventoryItemsLocationLevels } from "../../../../../hooks/api"
import { castNumber } from "../../../../../lib/cast-number"
import { useProductStockColumns } from "../../hooks/use-product-stock-columns"
import {
ProductStockInventoryItemSchema,
ProductStockLocationSchema,
ProductStockSchema,
ProductStockVariantSchema,
} from "../../schema"
import {
getDisabledInventoryRows,
isProductVariantWithInventoryPivot,
} from "../../utils"
type ProductStockFormProps = {
variants: HttpTypes.AdminProductVariant[]
locations: HttpTypes.AdminStockLocation[]
onLoaded: () => void
}
export const ProductStockForm = ({
variants,
locations,
onLoaded,
}: ProductStockFormProps) => {
const { t } = useTranslation()
const { handleSuccess, setCloseOnEscape } = useRouteModal()
const prompt = usePrompt()
useEffect(() => {
onLoaded()
}, [onLoaded])
const [isPromptOpen, setIsPromptOpen] = useState(false)
const form = useForm<ProductStockSchema>({
defaultValues: getDefaultValue(variants, locations),
resolver: zodResolver(ProductStockSchema),
})
const initialValues = useRef(getDefaultValue(variants, locations))
const disabled = useMemo(() => getDisabledInventoryRows(variants), [variants])
const columns = useProductStockColumns(locations, disabled)
const { mutateAsync, isPending } = useBatchInventoryItemsLocationLevels()
const onSubmit = form.handleSubmit(async (data) => {
const payload: HttpTypes.AdminBatchInventoryItemsLocationLevels = {
create: [],
update: [],
delete: [],
force: true,
}
for (const [variantId, variant] of Object.entries(data.variants)) {
for (const [inventory_item_id, item] of Object.entries(
variant.inventory_items
)) {
for (const [location_id, level] of Object.entries(item.locations)) {
if (level.id) {
const wasChecked =
initialValues.current?.variants?.[variantId]?.inventory_items?.[
inventory_item_id
]?.locations?.[location_id]?.checked
if (wasChecked && !level.checked) {
payload.delete.push(level.id)
} else {
const newQuantity =
level.quantity !== "" ? castNumber(level.quantity) : 0
const originalQuantity =
initialValues.current?.variants?.[variantId]?.inventory_items?.[
inventory_item_id
]?.locations?.[location_id]?.quantity
if (newQuantity !== originalQuantity) {
payload.update.push({
inventory_item_id,
location_id,
stocked_quantity: newQuantity,
})
}
}
}
if (!level.id && level.quantity !== "") {
payload.create.push({
inventory_item_id,
location_id,
stocked_quantity: castNumber(level.quantity),
})
}
}
}
}
if (payload.delete.length > 0) {
setIsPromptOpen(true)
const confirm = await prompt({
title: t("general.areYouSure"),
description: t("inventory.stock.disablePrompt", {
count: payload.delete.length,
}),
confirmText: t("actions.continue"),
cancelText: t("actions.cancel"),
variant: "confirmation",
})
setIsPromptOpen(false)
if (!confirm) {
return
}
}
await mutateAsync(payload, {
onSuccess: () => {
toast.success(t("inventory.stock.successToast"))
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
})
})
return (
<RouteFocusModal.Form form={form}>
<KeyboundForm onSubmit={onSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header />
<RouteFocusModal.Body className="flex-1">
<DataGrid
state={form}
columns={columns}
data={variants}
getSubRows={getSubRows}
onEditingChange={(editing) => setCloseOnEscape(!editing)}
disableInteractions={isPending || isPromptOpen}
multiColumnSelection={true}
/>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small" type="button">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button type="submit" size="small" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Footer>
</KeyboundForm>
</RouteFocusModal.Form>
)
}
function getSubRows(
row:
| HttpTypes.AdminProductVariant
| HttpTypes.AdminProductVariantInventoryItemLink
): HttpTypes.AdminProductVariantInventoryItemLink[] | undefined {
if (isProductVariantWithInventoryPivot(row)) {
return row.inventory_items
}
}
function getDefaultValue(
variants: HttpTypes.AdminProductVariant[],
locations: HttpTypes.AdminStockLocation[]
): DefaultValues<ProductStockSchema> {
return {
variants: variants.reduce((variantAcc, variant) => {
const inventoryItems = variant.inventory_items?.reduce(
(itemAcc, item) => {
const locationsMap = locations.reduce((locationAcc, location) => {
const level = item.inventory?.location_levels?.find(
(level) => level.location_id === location.id
)
locationAcc[location.id] = {
id: level?.id,
quantity:
level?.stocked_quantity !== undefined
? level?.stocked_quantity
: "",
checked: !!level,
disabledToggle:
(level?.incoming_quantity || 0) > 0 ||
(level?.reserved_quantity || 0) > 0,
}
return locationAcc
}, {} as ProductStockLocationSchema)
itemAcc[item.inventory_item_id] = { locations: locationsMap }
return itemAcc
},
{} as Record<string, ProductStockInventoryItemSchema>
)
variantAcc[variant.id] = { inventory_items: inventoryItems || {} }
return variantAcc
}, {} as Record<string, ProductStockVariantSchema>),
}
}
@@ -0,0 +1,226 @@
import { InformationCircle } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import { Switch, Tooltip } from "@medusajs/ui"
import { useCallback, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Thumbnail } from "../../../../components/common/thumbnail"
import { createDataGridHelper } from "../../../../components/data-grid"
import { DataGridReadOnlyCell } from "../../../../components/data-grid/components"
import { DataGridDuplicateCell } from "../../../../components/data-grid/components/data-grid-duplicate-cell"
import { DataGridTogglableNumberCell } from "../../../../components/data-grid/components/data-grid-toggleable-number-cell"
import { ProductStockSchema } from "../schema"
import { isProductVariant } from "../utils"
const helper = createDataGridHelper<
| HttpTypes.AdminProductVariant
| HttpTypes.AdminProductVariantInventoryItemLink,
ProductStockSchema
>()
type DisabledItem = { id: string; title: string; sku: string }
type DisabledResult =
| {
isDisabled: true
item: DisabledItem
}
| {
isDisabled: false
item: undefined
}
export const useProductStockColumns = (
locations: HttpTypes.AdminStockLocation[] = [],
disabled: Record<string, DisabledItem> = {}
) => {
const { t } = useTranslation()
const getIsDisabled = useCallback(
(item: HttpTypes.AdminProductVariantInventoryItemLink): DisabledResult => {
const disabledItem = disabled[item.inventory_item_id]
const isDisabled = !!disabledItem && disabledItem.id !== item.variant_id
if (!isDisabled) {
return {
isDisabled: false,
item: undefined,
}
}
return {
isDisabled,
item: disabledItem,
}
},
[disabled]
)
return useMemo(
() => [
helper.column({
id: "title",
name: "Title",
header: "Title",
cell: (context) => {
const item = context.row.original
if (isProductVariant(item)) {
return (
<DataGridReadOnlyCell context={context}>
<div className="flex items-center gap-x-2">
<Thumbnail size="small" src={item.product?.thumbnail} />
<span>{item.title || "-"}</span>
</div>
</DataGridReadOnlyCell>
)
}
const { isDisabled, item: disabledItem } = getIsDisabled(item)
if (isDisabled) {
return (
<DataGridReadOnlyCell context={context} color="normal">
<div className="flex size-full items-center justify-between gap-x-2">
<span
title={item.inventory?.title || undefined}
className="text-ui-fg-disabled"
>
{item.inventory?.title || "-"}
</span>
<Tooltip
content={
disabledItem.sku
? t("products.stock.tooltips.alreadyManagedWithSku", {
title: disabledItem.title,
sku: disabledItem.sku,
})
: t("products.stock.tooltips.alreadyManaged", {
title: disabledItem.title,
})
}
>
<InformationCircle />
</Tooltip>
</div>
</DataGridReadOnlyCell>
)
}
return (
<DataGridReadOnlyCell context={context} color="normal">
{item.inventory?.title || "-"}
</DataGridReadOnlyCell>
)
},
disableHiding: true,
}),
helper.column({
id: "sku",
name: "SKU",
header: "SKU",
cell: (context) => {
const item = context.row.original
if (isProductVariant(item)) {
return (
<DataGridReadOnlyCell context={context}>
{item.sku || "-"}
</DataGridReadOnlyCell>
)
}
const { isDisabled } = getIsDisabled(item)
if (isDisabled) {
return (
<DataGridReadOnlyCell context={context} color="normal">
<span className="text-ui-fg-disabled">
{item.inventory?.sku || "-"}
</span>
</DataGridReadOnlyCell>
)
}
return (
<DataGridReadOnlyCell context={context} color="normal">
{item.inventory?.sku || "-"}
</DataGridReadOnlyCell>
)
},
disableHiding: true,
}),
...locations.map((location) =>
helper.column({
id: `location_${location.id}`,
name: location.name,
header: location.name,
field: (context) => {
const item = context.row.original
if (isProductVariant(item)) {
return null
}
const { isDisabled } = getIsDisabled(item)
if (isDisabled) {
return null
}
return `variants.${item.variant_id}.inventory_items.${item.inventory_item_id}.locations.${location.id}` as const
},
type: "togglable-number",
cell: (context) => {
const item = context.row.original
if (isProductVariant(item)) {
return <DataGridReadOnlyCell context={context} />
}
const { isDisabled, item: disabledItem } = getIsDisabled(item)
if (isDisabled) {
return (
<DataGridDuplicateCell
duplicateOf={`variants.${disabledItem.id}.inventory_items.${item.inventory_item_id}.locations.${location.id}`}
>
{({ value }) => {
const { checked, quantity } = value as {
checked: boolean
quantity: number | string
}
return (
<div className="flex size-full items-center gap-x-2">
<Switch
className="shrink-0 cursor-not-allowed"
tabIndex={-1}
size="small"
checked={checked}
disabled
/>
<span className="text-ui-fg-disabled flex size-full items-center justify-end">
{quantity}
</span>
</div>
)
}}
</DataGridDuplicateCell>
)
}
return (
<DataGridTogglableNumberCell
context={context}
disabledToggleTooltip={t(
"inventory.stock.disabledToggleTooltip"
)}
placeholder={t("inventory.stock.placeholder")}
/>
)
},
})
),
],
[locations, getIsDisabled, t]
)
}
@@ -0,0 +1,2 @@
export { productStockLoader as loader } from "./loader"
export { ProductStock as Component } from "./product-stock"
@@ -0,0 +1,55 @@
import { HttpTypes } from "@medusajs/types"
import { defer, LoaderFunctionArgs } from "react-router-dom"
import { sdk } from "../../../lib/client"
import { PRODUCT_VARIANT_IDS_KEY } from "../common/constants"
async function getProductStockData(id: string, productVariantIds?: string[]) {
const CHUNK_SIZE = 20
let offset = 0
let totalCount = 0
let allVariants: HttpTypes.AdminProductVariant[] = []
do {
const { variants: chunk, count } = await sdk.admin.product.listVariants(
id,
{
id: productVariantIds,
offset,
limit: CHUNK_SIZE,
fields:
"id,title,sku,inventory_items,inventory_items.*,inventory_items.inventory,inventory_items.inventory.id,inventory_items.inventory.title,inventory_items.inventory.sku,*inventory_items.inventory.location_levels,product.thumbnail",
}
)
allVariants = [...allVariants, ...chunk]
totalCount = count
offset += CHUNK_SIZE
} while (allVariants.length < totalCount)
const { stock_locations } = await sdk.admin.stockLocation.list({
limit: 9999,
fields: "id,name",
})
return {
variants: allVariants,
locations: stock_locations,
}
}
export const productStockLoader = async ({
params,
request,
}: LoaderFunctionArgs) => {
const id = params.id!
const searchParams = new URLSearchParams(request.url)
const productVariantIds =
searchParams.get(PRODUCT_VARIANT_IDS_KEY)?.split(",") || undefined
const dataPromise = getProductStockData(id, productVariantIds)
return defer({
data: dataPromise,
})
}
@@ -0,0 +1,106 @@
import { HttpTypes } from "@medusajs/types"
import { AnimatePresence } from "motion/react"
import { Suspense, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { Await, useLoaderData } from "react-router-dom"
import { ColumnDef } from "@tanstack/react-table"
import { ProgressBar } from "../../../components/common/progress-bar"
import { Skeleton } from "../../../components/common/skeleton"
import { DataGridSkeleton } from "../../../components/data-grid/components"
import { RouteFocusModal } from "../../../components/modals"
import { ProductStockForm } from "./components/product-stock-form"
import { productStockLoader } from "./loader"
export const ProductStock = () => {
const { t } = useTranslation()
const data = useLoaderData() as Awaited<ReturnType<typeof productStockLoader>>
/**
* We render a local ProgressBar, as we cannot rely on the global NavigationBar.
* This is because we are deferring the data, meaning that the navigation is
* instant, and the data is loaded in parallel with the navigation, but may resolve
* after the navigation has completed. This will result in the data loading after the
* navigation has completed most of the time for this route, as we chunk the data into
* multiple queries.
*
* Here we instead render a local ProgressBar, which is animated, and exit
* the animation when the data is loaded, and the form is rendered.
*/
const [isLoading, setIsLoading] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
timeoutRef.current = setTimeout(() => {
setIsLoading(true)
}, 200)
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const onLoaded = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setIsLoading(false)
}
return (
<div>
<div className="fixed inset-x-0 top-0 z-50 h-1">
<AnimatePresence>
{isLoading ? <ProgressBar duration={5} /> : null}
</AnimatePresence>
</div>
<RouteFocusModal>
<RouteFocusModal.Title asChild>
<span className="sr-only">{t("products.stock.heading")}</span>
</RouteFocusModal.Title>
<RouteFocusModal.Description asChild>
<span className="sr-only">{t("products.stock.description")}</span>
</RouteFocusModal.Description>
<Suspense fallback={<ProductStockFallback />}>
<Await resolve={data.data}>
{(data: {
variants: HttpTypes.AdminProductVariant[]
locations: HttpTypes.AdminStockLocation[]
}) => {
return (
<ProductStockForm
variants={data.variants}
locations={data.locations}
onLoaded={onLoaded}
/>
)
}}
</Await>
</Suspense>
</RouteFocusModal>
</div>
)
}
const ProductStockFallback = () => {
return (
<div className="relative flex size-full flex-col items-center justify-center divide-y">
<div className="flex size-full flex-col divide-y">
<div className="px-4 py-2">
<Skeleton className="h-7 w-7" />
</div>
<div className="flex-1 overflow-auto">
<DataGridSkeleton
columns={Array.from({ length: 10 }) as ColumnDef<any>[]}
/>
</div>
<div className="bg-ui-bg-base flex items-center justify-end gap-x-2 p-4">
<Skeleton className="h-7 w-[59px]" />
<Skeleton className="h-7 w-[46px]" />
</div>
</div>
</div>
)
}
@@ -0,0 +1,33 @@
import { z } from "zod"
const LocationQuantitySchema = z.object({
id: z.string().optional(),
quantity: z.union([z.number(), z.string()]),
checked: z.boolean(),
disabledToggle: z.boolean(),
})
const ProductStockLocationsSchema = z.record(LocationQuantitySchema)
const ProductStockInventoryItemSchema = z.object({
locations: ProductStockLocationsSchema,
})
const ProductStockVariantSchema = z.object({
inventory_items: z.record(ProductStockInventoryItemSchema),
})
export const ProductStockSchema = z.object({
variants: z.record(ProductStockVariantSchema),
})
export type ProductStockLocationSchema = z.infer<
typeof ProductStockLocationsSchema
>
export type ProductStockInventoryItemSchema = z.infer<
typeof ProductStockInventoryItemSchema
>
export type ProductStockVariantSchema = z.infer<
typeof ProductStockVariantSchema
>
export type ProductStockSchema = z.infer<typeof ProductStockSchema>
@@ -0,0 +1,53 @@
import { HttpTypes } from "@medusajs/types"
export function isProductVariant(
row:
| HttpTypes.AdminProductVariant
| HttpTypes.AdminProductVariantInventoryItemLink
): row is HttpTypes.AdminProductVariant {
return row.id.startsWith("variant_")
}
export function isProductVariantWithInventoryPivot(
row:
| HttpTypes.AdminProductVariant
| HttpTypes.AdminProductVariantInventoryItemLink
): row is HttpTypes.AdminProductVariant & {
inventory_items: HttpTypes.AdminProductVariantInventoryItemLink[]
} {
return (row as any).inventory_items && (row as any).inventory_items.length > 0
}
export function getDisabledInventoryRows(
variants: HttpTypes.AdminProductVariant[]
) {
const seen: Record<string, HttpTypes.AdminProductVariant> = {}
const disabled: Record<string, { id: string; title: string; sku: string }> =
{}
variants.forEach((variant) => {
const inventoryItems = variant.inventory_items
if (!inventoryItems) {
return
}
inventoryItems.forEach((item) => {
const existing = seen[item.inventory_item_id]
if (existing) {
disabled[item.inventory_item_id] = {
id: existing.id,
title: existing.title || "",
sku: existing.sku || "",
}
return
}
seen[item.inventory_item_id] = variant
})
})
return disabled
}
@@ -5,11 +5,12 @@ import {
useAnimationControls,
useDragControls,
useMotionValue,
} from "framer-motion"
} from "motion/react"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
import { HttpTypes } from "@medusajs/types"
import {
STEP_ERROR_STATES,
STEP_INACTIVE_STATES,
@@ -17,7 +18,6 @@ import {
STEP_OK_STATES,
STEP_SKIPPED_STATES,
} from "../../../constants"
import { HttpTypes } from "@medusajs/types"
type WorkflowExecutionTimelineSectionProps = {
execution: HttpTypes.AdminWorkflowExecutionResponse["workflow_execution"]
@@ -11,7 +11,6 @@ export const createInventoryLevelsStep = createStep(
createInventoryLevelsStepId,
async (data: InventoryTypes.CreateInventoryLevelInput[], { container }) => {
const service = container.resolve<IInventoryService>(Modules.INVENTORY)
const inventoryLevels = await service.createInventoryLevels(data)
return new StepResponse(
inventoryLevels,
@@ -13,10 +13,7 @@ export const updateInventoryLevelsStepId = "update-inventory-levels-step"
*/
export const updateInventoryLevelsStep = createStep(
updateInventoryLevelsStepId,
async (
input: InventoryTypes.BulkUpdateInventoryLevelInput[],
{ container }
) => {
async (input: InventoryTypes.UpdateInventoryLevelInput[], { container }) => {
const inventoryService: IInventoryService = container.resolve(
Modules.INVENTORY
)
@@ -54,7 +51,7 @@ export const updateInventoryLevelsStep = createStep(
await inventoryService.updateInventoryLevels(
dataBeforeUpdate.map((data) =>
convertItemResponseToUpdateRequest(data, selects, relations)
) as InventoryTypes.BulkUpdateInventoryLevelInput[]
) as InventoryTypes.UpdateInventoryLevelInput[]
)
}
)
@@ -0,0 +1,68 @@
import {
createWorkflow,
parallelize,
transform,
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { BatchWorkflowInput, InventoryTypes } from "@medusajs/types"
import { createInventoryLevelsStep, updateInventoryLevelsStep } from "../steps"
import { deleteInventoryLevelsWorkflow } from "./delete-inventory-levels"
export interface BatchInventoryItemLevelsWorkflowInput
extends BatchWorkflowInput<
InventoryTypes.CreateInventoryLevelInput,
InventoryTypes.UpdateInventoryLevelInput
> {
/**
* If true, the workflow will force deletion of the inventory levels, even
* if they have a non-zero stocked quantity. It false, the workflow will
* not delete the inventory levels if they have a non-zero stocked quantity.
*
* Inventory levels that have reserved or incoming items at the location
* will not be deleted even if the force flag is set to true.
*
* @default false
*/
force?: boolean
}
export const batchInventoryItemLevelsWorkflowId =
"batch-inventory-item-levels-workflow"
export const batchInventoryItemLevelsWorkflow = createWorkflow(
batchInventoryItemLevelsWorkflowId,
(input: WorkflowData<BatchInventoryItemLevelsWorkflowInput>) => {
const { createInput, updateInput, deleteInput } = transform(
input,
(data) => {
return {
createInput: data.create || [],
updateInput: data.update || [],
deleteInput: {
id: data.delete || [],
force: data.force ?? false,
},
}
}
)
const res = parallelize(
createInventoryLevelsStep(createInput),
updateInventoryLevelsStep(updateInput),
deleteInventoryLevelsWorkflow.runAsStep({
input: deleteInput,
})
)
return new WorkflowResponse(
transform({ res, input }, (data) => {
return {
created: data.res[0],
updated: data.res[1],
deleted: data.input.delete,
}
})
)
}
)
@@ -1,3 +1,5 @@
// TODO: Remove this workflow in a future release.
import { InventoryLevelDTO, InventoryTypes } from "@medusajs/framework/types"
import {
createWorkflow,
@@ -17,6 +19,8 @@ export const bulkCreateDeleteLevelsWorkflowId =
"bulk-create-delete-levels-workflow"
/**
* This workflow creates and deletes inventory levels.
*
* @deprecated Use `batchInventoryItemLevels` instead.
*/
export const bulkCreateDeleteLevelsWorkflow = createWorkflow(
bulkCreateDeleteLevelsWorkflowId,
@@ -6,7 +6,10 @@ import {
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { FilterableInventoryLevelProps } from "@medusajs/framework/types"
import {
FilterableInventoryLevelProps,
InventoryLevelDTO,
} from "@medusajs/framework/types"
import { deduplicate, MedusaError, Modules } from "@medusajs/framework/utils"
import { useRemoteQueryStep } from "../../common"
import { deleteEntitiesStep } from "../../common/steps/delete-entities"
@@ -16,24 +19,52 @@ import { deleteEntitiesStep } from "../../common/steps/delete-entities"
*/
export const validateInventoryLevelsDelete = createStep(
"validate-inventory-levels-delete",
async function ({ inventoryLevels }: { inventoryLevels: any[] }) {
const undeleteableItems = inventoryLevels.filter(
(i) => i.reserved_quantity > 0 || i.stocked_quantity > 0
async function ({
inventoryLevels,
force,
}: {
inventoryLevels: InventoryLevelDTO[]
force?: boolean
}) {
const undeleteableDueToReservation = inventoryLevels.filter(
(i) => i.reserved_quantity > 0 || i.incoming_quantity > 0
)
if (undeleteableItems.length) {
const stockLocationIds = deduplicate(
undeleteableItems.map((item) => item.location_id)
if (undeleteableDueToReservation.length) {
const locationIds = deduplicate(
undeleteableDueToReservation.map((item) => item.location_id)
)
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Cannot remove Inventory Levels for ${stockLocationIds} because there are stocked or reserved items at the locations`
`Cannot remove Inventory Levels for ${locationIds.join(
", "
)} because there are reserved or incoming items at the locations`
)
}
const undeleteableDueToStock = inventoryLevels.filter(
(i) => !force && i.stocked_quantity > 0
)
if (undeleteableDueToStock.length) {
const locationIds = deduplicate(
undeleteableDueToStock.map((item) => item.location_id)
)
throw new MedusaError(
MedusaError.Types.NOT_ALLOWED,
`Cannot remove Inventory Levels for ${locationIds.join(
", "
)} because there are stocked items at the locations. Use force flag to delete anyway.`
)
}
}
)
export interface DeleteInventoryLevelsWorkflowInput
extends FilterableInventoryLevelProps {
force?: boolean
}
export const deleteInventoryLevelsWorkflowId =
"delete-inventory-levels-workflow"
/**
@@ -41,16 +72,25 @@ export const deleteInventoryLevelsWorkflowId =
*/
export const deleteInventoryLevelsWorkflow = createWorkflow(
deleteInventoryLevelsWorkflowId,
(input: WorkflowData<FilterableInventoryLevelProps>) => {
(input: WorkflowData<DeleteInventoryLevelsWorkflowInput>) => {
const { filters, force } = transform(input, (data) => {
const { force, ...filters } = data
return {
filters,
force,
}
})
const inventoryLevels = useRemoteQueryStep({
entry_point: "inventory_levels",
fields: ["id", "stocked_quantity", "reserved_quantity", "location_id"],
variables: {
filters: input,
filters: filters,
},
})
validateInventoryLevelsDelete({ inventoryLevels })
validateInventoryLevelsDelete({ inventoryLevels, force })
const idsToDelete = transform({ inventoryLevels }, ({ inventoryLevels }) =>
inventoryLevels.map((il) => il.id)
@@ -1,7 +1,8 @@
export * from "./delete-inventory-items"
export * from "./batch-inventory-item-levels"
export * from "./bulk-create-delete-levels"
export * from "./create-inventory-items"
export * from "./create-inventory-levels"
export * from "./update-inventory-items"
export * from "./delete-inventory-items"
export * from "./delete-inventory-levels"
export * from "./update-inventory-items"
export * from "./update-inventory-levels"
export * from "./bulk-create-delete-levels"
@@ -8,7 +8,7 @@ import {
import { updateInventoryLevelsStep } from "../steps/update-inventory-levels"
export interface UpdateInventoryLevelsWorkflowInput {
updates: InventoryTypes.BulkUpdateInventoryLevelInput[]
updates: InventoryTypes.UpdateInventoryLevelInput[]
}
export const updateInventoryLevelsWorkflowId =
"update-inventory-levels-workflow"
+121 -48
View File
@@ -15,15 +15,15 @@ export class InventoryItem {
}
/**
* This method creates an inventory item. It sends a request to the
* This method creates an inventory item. It sends a request to the
* [Create Inventory Item](https://docs.medusajs.com/api/admin#inventory-items_postinventoryitems)
* API route.
*
*
* @param body - The inventory item's details.
* @param query - Configure the fields to retrieve in the inventory item.
* @param headers - Headers to pass in the request.
* @returns The inventory item's details.
*
*
* @example
* sdk.admin.inventoryItem.create({
* sku: "SHIRT"
@@ -52,13 +52,13 @@ export class InventoryItem {
* This method updates an inventory level. It sends a request to the
* [Update Inventory Item](https://docs.medusajs.com/api/admin#inventory-items_postinventoryitemsid)
* API route.
*
*
* @param id - The inventory item's ID.
* @param body - The data to update.
* @param query - Configure the fields to retrieve in the inventory item.
* @param headers - Headers to pass in the request.
* @returns The inventory item's details.
*
*
* @example
* sdk.admin.inventoryItem.update("iitem_123", {
* sku: "SHIRT"
@@ -85,28 +85,28 @@ export class InventoryItem {
}
/**
* This method retrieves a paginated list of inventory items. It sends a request to the
* This method retrieves a paginated list of inventory items. It sends a request to the
* [List Inventory Items](https://docs.medusajs.com/api/admin#inventory-items_getinventoryitems)
* API route.
*
*
* @param query - Filters and pagination configurations.
* @param headers - Headers to pass in the request.
* @returns The paginated list of inventory items.
*
*
* @example
* To retrieve the list of inventory items:
*
*
* ```ts
* sdk.admin.inventoryItem.list()
* .then(({ inventory_items, count, limit, offset }) => {
* console.log(inventory_items)
* })
* ```
*
*
* To configure the pagination, pass the `limit` and `offset` query parameters.
*
*
* For example, to retrieve only 10 items and skip 10 items:
*
*
* ```ts
* sdk.admin.inventoryItem.list({
* limit: 10,
@@ -116,10 +116,10 @@ export class InventoryItem {
* console.log(inventory_items)
* })
* ```
*
*
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
* in each inventory item:
*
*
* ```ts
* sdk.admin.inventoryItem.list({
* fields: "id,*location_levels"
@@ -128,7 +128,7 @@ export class InventoryItem {
* console.log(inventory_items)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async list(
@@ -145,26 +145,26 @@ export class InventoryItem {
}
/**
* This method retrieves an inventory item by its ID. It sends a request to the
* This method retrieves an inventory item by its ID. It sends a request to the
* [Get Inventory Item](https://docs.medusajs.com/api/admin#inventory-items_getinventoryitemsid) API route.
*
*
* @param id - The inventory item's ID.
* @param query - Configure the fields to retrieve in the inventory item.
* @param headers - Headers to pass in the request
* @returns The inventory item's details.
*
*
* @example
* To retrieve an inventory item by its ID:
*
*
* ```ts
* sdk.admin.inventoryItem.retrieve("iitem_123")
* .then(({ inventory_item }) => {
* console.log(inventory_item)
* })
* ```
*
*
* To specify the fields and relations to retrieve:
*
*
* ```ts
* sdk.admin.inventoryItem.retrieve("iitem_123", {
* fields: "id,*location_levels"
@@ -173,7 +173,7 @@ export class InventoryItem {
* console.log(inventory_item)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async retrieve(id: string, query?: SelectParams, headers?: ClientHeaders) {
@@ -190,11 +190,11 @@ export class InventoryItem {
* This method deletes an inventory item. This sends a request to the
* [Delete Inventory Item](https://docs.medusajs.com/api/admin#inventory-items_deleteinventoryitemsid)
* API route.
*
*
* @param id - The inventory item's ID.
* @param headers - Headers to pass in the request
* @returns The deletion's details.
*
*
* @example
* sdk.admin.inventoryItem.delete("iitem_123")
* .then(({ deleted }) => {
@@ -215,26 +215,26 @@ export class InventoryItem {
* This method retrieves a paginated list of inventory levels that belong to an inventory item.
* It sends a request to the [List Inventory Items](https://docs.medusajs.com/api/admin#inventory-items_getinventoryitems)
* API route.
*
*
* @param id - The inventory item's ID.
* @param query - Filters and pagination configurations.
* @param headers - Headers to pass in the request.
* @returns The paginated list of inventory levels.
*
*
* @example
* To retrieve the list of inventory levels:
*
*
* ```ts
* sdk.admin.inventoryItem.listLevels("iitem_123")
* .then(({ inventory_levels, count, limit, offset }) => {
* console.log(inventory_levels)
* })
* ```
*
*
* To configure the pagination, pass the `limit` and `offset` query parameters.
*
*
* For example, to retrieve only 10 items and skip 10 items:
*
*
* ```ts
* sdk.admin.inventoryItem.listLevels("iitem_123", {
* limit: 10,
@@ -244,10 +244,10 @@ export class InventoryItem {
* console.log(inventory_levels)
* })
* ```
*
*
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
* in each inventory level:
*
*
* ```ts
* sdk.admin.inventoryItem.listLevels("iitem_123", {
* fields: "id,*inventory_item"
@@ -256,7 +256,7 @@ export class InventoryItem {
* console.log(inventory_levels)
* })
* ```
*
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async listLevels(
@@ -276,18 +276,18 @@ export class InventoryItem {
/**
* This method updates the inventory level of the specified inventory item and
* stock location.
*
* This method sends a request to the
*
* This method sends a request to the
* [Update Inventory Level](https://docs.medusajs.com/api/admin#inventory-items_postinventoryitemsidlocationlevelslocation_id)
* API route.
*
*
* @param id - The inventory item's ID.
* @param locationId - The stock location's ID.
* @param body - The details to update.
* @param query - Configure the fields to retrieve in the inventory item.
* @param headers - Headers to pass in the request
* @returns The inventory item's details.
*
*
* @example
* sdk.admin.inventoryItem.updateLevel(
* "iitem_123",
@@ -321,16 +321,16 @@ export class InventoryItem {
/**
* This method deletes an inventory level associated with an inventory item
* and a stock location.
*
* This method sends a request to the
*
* This method sends a request to the
* [Remove Inventory Level](https://docs.medusajs.com/api/admin#inventory-items_deleteinventoryitemsidlocationlevelslocation_id)
* API route.
*
*
* @param id - The inventory item's ID.
* @param locationId - The stock location's ID.
* @param headers - Headers to pass in the request
* @returns The deletion's details.
*
*
* @example
* sdk.admin.inventoryItem.deleteLevel(
* "iitem_123",
@@ -354,32 +354,34 @@ export class InventoryItem {
* This method manages the inventory levels of an inventory item. It sends a request to the
* [Manage Inventory Levels](https://docs.medusajs.com/api/admin#inventory-items_postinventoryitemsidlocationlevelsbatch)
* API route.
*
*
* @deprecated Use `batchInventoryItemLocationLevels` instead.
*
* @param id - The inventory item's ID.
* @param body - The inventory levels to create or delete.
* @param query - Configure the fields to retrieve in the inventory item.
* @param headers - Headers to pass in the request
* @returns The inventory item's details.
*
*
* @example
* sdk.admin.inventoryItem.batchUpdateLevels("iitem_123", {
* create: [{
* location_id: "sloc_123",
* stocked_quantity: 10
* }],
* delete: ["sloc_123"]
* delete: ["ilvl_123"]
* })
* .then(({ inventory_item }) => {
* console.log(inventory_item)
* .then(({ created, updated, deleted }) => {
* console.log(created, updated, deleted)
* })
*/
async batchUpdateLevels(
id: string,
body: HttpTypes.AdminBatchUpdateInventoryLevelLocation,
body: HttpTypes.AdminBatchInventoryItemLocationLevels,
query?: SelectParams,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminInventoryItemResponse>(
return await this.client.fetch<HttpTypes.AdminBatchInventoryItemLocationLevelsResponse>(
`/admin/inventory-items/${id}/location-levels/batch`,
{
method: "POST",
@@ -389,4 +391,75 @@ export class InventoryItem {
}
)
}
/**
* This method manages the inventory levels of an inventory item. It sends a request to the
* [Manage Inventory Levels](https://docs.medusajs.com/api/admin#inventory-items_postinventoryitemsidlocationlevelsbatch)
* API route.
*
* @param id - The inventory item's ID.
* @param body - The inventory levels to create, update or delete, and an optional `force` flag.
* @param headers - Headers to pass in the request
* @returns The inventory item's details.
*
* @example
* sdk.admin.inventoryItem.batchInventoryItemLocationLevels("iitem_123", {
* create: [{
* location_id: "sloc_123",
* stocked_quantity: 10
* }],
* delete: ["ilvl_123"]
* })
* .then(({ created, updated, deleted }) => {
* console.log(created, updated, deleted)
* })
*/
async batchInventoryItemLocationLevels(
id: string,
body: HttpTypes.AdminBatchInventoryItemLocationLevels,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminBatchInventoryItemLocationLevelsResponse>(
`/admin/inventory-items/${id}/location-levels/batch`,
{
method: "POST",
headers,
body,
}
)
}
/**
* This method manages the inventory levels of multiple inventory items.
*
* @param body - The inventory levels to create, update or delete, and an optional `force` flag.
* @param headers - Headers to pass in the request
* @returns The inventory item's details.
*
* @example
* sdk.admin.inventoryItem.batchInventoryItemsLocationLevels({
* create: [{
* inventory_item_id: "iitem_123",
* location_id: "sloc_123",
* stocked_quantity: 10
* }],
* delete: ["ilvl_123"]
* })
* .then(({ created, updated, deleted }) => {
* console.log(created, updated, deleted)
* })
*/
async batchInventoryItemsLocationLevels(
body: HttpTypes.AdminBatchInventoryItemsLocationLevels,
headers?: ClientHeaders
) {
return await this.client.fetch<HttpTypes.AdminBatchInventoryItemsLocationLevelsResponse>(
`/admin/inventory-items/location-levels/batch`,
{
method: "POST",
headers,
body,
}
)
}
}
@@ -11,7 +11,7 @@ export interface AdminUpdateInventoryLevel {
incoming_quantity?: number
}
export interface AdminCreateInventoryLevel {
export interface AdminBatchCreateInventoryItemLocationLevels {
/**
* The ID of the associated stock location.
*/
@@ -28,19 +28,85 @@ export interface AdminCreateInventoryLevel {
incoming_quantity?: number
}
export interface AdminBatchUpdateInventoryLevelLocation {
export interface AdminBatchCreateInventoryItemLocationLevels {
/**
* The ID of the associated stock location.
*/
location_id: string
/**
* The associated inventory item's stocked quantity in the
* associated stock location.
*/
stocked_quantity?: number
/**
* The associated inventory item's incoming quantity in the
* associated stock location.
*/
incoming_quantity?: number
}
export interface AdminBatchUpdateInventoryItemLocationLevels
extends AdminBatchCreateInventoryItemLocationLevels {
/**
* The ID of the inventory level to update.
*/
id?: string
}
export interface AdminBatchInventoryItemLocationLevels {
/**
* A list of inventory levels to update.
*/
update?: AdminBatchUpdateInventoryItemLocationLevels[]
/**
* A list of inventory levels to create.
*/
create?: AdminBatchCreateInventoryItemLocationLevels[]
/**
* A list of location IDs to
* delete their associated inventory
* delete their associated inventory
* levels of the inventory item.
*/
delete?: string[]
/**
* @ignore
* Whether to force the deletion of the inventory levels,
* even if the the location has stocked quantity.
*/
update?: never // TODO - not implemented // AdminUpdateInventoryLevel[]
force?: boolean
}
export interface AdminBatchCreateInventoryItemsLocationLevels {
/**
* A list of inventory levels to create.
* The ID of the associated stock location.
*/
create?: AdminCreateInventoryLevel[]
location_id: string
/**
* The ID of the associated inventory item.
*/
inventory_item_id: string
/**
* The associated inventory item's stocked quantity in the
* associated stock location.
*/
stocked_quantity?: number
/**
* The associated inventory item's incoming quantity in the
* associated stock location.
*/
incoming_quantity?: number
}
export interface AdminBatchUpdateInventoryItemsLocationLevels
extends AdminBatchCreateInventoryItemsLocationLevels {
/**
* The ID of the inventory level to update.
*/
id?: string
}
export interface AdminBatchInventoryItemsLocationLevels {
create: AdminBatchCreateInventoryItemsLocationLevels[]
update: AdminBatchUpdateInventoryItemsLocationLevels[]
delete: string[]
force?: boolean
}
@@ -14,3 +14,21 @@ export type AdminInventoryLevelListResponse = PaginatedResponse<{
*/
inventory_levels: InventoryLevel[]
}>
export interface AdminBatchInventoryItemLocationLevelsResponse {
/**
* The created inventory levels.
*/
created?: InventoryLevel[]
/**
* The updated inventory levels.
*/
updated?: InventoryLevel[]
/**
* The IDs of the deleted inventory levels.
*/
deleted?: string[]
}
export interface AdminBatchInventoryItemsLocationLevelsResponse
extends AdminBatchInventoryItemLocationLevelsResponse {}
@@ -1,4 +1,5 @@
import { AdminCollection } from "../../collection"
import { AdminInventoryItem } from "../../inventory"
import { AdminPrice } from "../../pricing"
import { AdminProductCategory } from "../../product-category"
import { AdminProductTag } from "../../product-tag"
@@ -13,6 +14,29 @@ import {
ProductStatus,
} from "../common"
export interface AdminProductVariantInventoryItemLink {
/**
* The ID of the pivot record.
*/
id: string
/**
* The ID of the variant.
*/
variant_id: string
/**
* The variant that the inventory item is linked to.
*/
variant?: AdminProductVariant
/**
* The ID of the inventory item.
*/
inventory_item_id: string
/**
* The inventory item that is linked to the variant.
*/
inventory?: AdminInventoryItem
}
export interface AdminProductVariant extends BaseProductVariant {
/**
* The product variant's prices.
@@ -26,6 +50,10 @@ export interface AdminProductVariant extends BaseProductVariant {
* The product that this variant belongs to.
*/
product?: AdminProduct | null
/**
* The variant's inventory items.
*/
inventory_items?: AdminProductVariantInventoryItemLink[] | null
}
export interface AdminProductOption extends BaseProductOption {
/**
@@ -26,9 +26,17 @@ export interface CreateInventoryLevelInput {
*/
export interface UpdateInventoryLevelInput {
/**
* id of the inventory level to update
* ID of the inventory level to update
*/
id?: string
/**
* The ID of the associated inventory item.
*/
inventory_item_id: string
/**
* The ID of the associated location.
*/
location_id: string
/**
* The stocked quantity of the associated inventory item in the associated location.
*/
@@ -39,22 +47,6 @@ export interface UpdateInventoryLevelInput {
incoming_quantity?: number
}
/**
* @interface
*
* The attributes to update in an inventory level. The inventory level is identified by the IDs of its associated inventory item and location.
*/
export type BulkUpdateInventoryLevelInput = {
/**
* The ID of the associated inventory level.
*/
inventory_item_id: string
/**
* The ID of the associated location.
*/
location_id: string
} & UpdateInventoryLevelInput
export type BulkAdjustInventoryLevelInput = {
/**
* The ID of the associated inventory level.
@@ -69,4 +61,4 @@ export type BulkAdjustInventoryLevelInput = {
* The quantity to adjust the inventory level by.
*/
adjustment: BigNumberInput
} & UpdateInventoryLevelInput
}
+6 -6
View File
@@ -13,11 +13,11 @@ import {
ReservationItemDTO,
} from "./common"
import {
BulkUpdateInventoryLevelInput,
CreateInventoryItemInput,
CreateInventoryLevelInput,
CreateReservationItemInput,
UpdateInventoryItemInput,
UpdateInventoryLevelInput,
UpdateReservationItemInput,
} from "./mutations"
@@ -610,7 +610,7 @@ export interface IInventoryService extends IModuleService {
/**
* This method updates existing inventory levels.
*
* @param {BulkUpdateInventoryLevelInput[]} updates - The list of The attributes to update in an inventory level. The inventory level is identified by the IDs of its associated inventory item and location.
* @param {UpdateInventoryLevelInput[]} updates - The list of The attributes to update in an inventory level. The inventory level is identified by the IDs of its associated inventory item and location.
* @param {Context} context - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<InventoryLevelDTO[]>} The updated inventory levels.
*
@@ -626,14 +626,14 @@ export interface IInventoryService extends IModuleService {
* ])
*/
updateInventoryLevels(
updates: BulkUpdateInventoryLevelInput[],
updates: UpdateInventoryLevelInput[],
context?: Context
): Promise<InventoryLevelDTO[]>
/**
* This method updates an existing inventory level.
*
* @param {BulkUpdateInventoryLevelInput} updates - The attributes to update in an inventory level. The inventory level is identified by the IDs of its associated inventory item and location.
* @param {UpdateInventoryLevelInput} updates - The attributes to update in an inventory level. The inventory level is identified by the IDs of its associated inventory item and location.
* @param {Context} context - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<InventoryLevelDTO>} The updated inventory level.
*
@@ -646,7 +646,7 @@ export interface IInventoryService extends IModuleService {
* })
*/
updateInventoryLevels(
updates: BulkUpdateInventoryLevelInput,
updates: UpdateInventoryLevelInput,
context?: Context
): Promise<InventoryLevelDTO>
@@ -1058,7 +1058,7 @@ export interface IInventoryService extends IModuleService {
): Promise<InventoryLevelDTO[]>
/**
*
*
* @param {string} inventoryItemId - The inventory item's ID.
* @param {string} locationId - The location's ID.
* @param {number} adjustment - the adjustment to make to the quantity.
@@ -23,7 +23,16 @@ const Checkbox = React.forwardRef<
className
)}
>
<div className="text-ui-fg-on-inverted bg-ui-bg-base shadow-borders-base group-hover:bg-ui-bg-base-hover group-focus-visible:!shadow-borders-interactive-with-focus group-data-[state=checked]:bg-ui-bg-interactive group-data-[state=checked]:shadow-borders-interactive-with-shadow group-data-[state=indeterminate]:bg-ui-bg-interactive group-data-[state=indeterminate]:shadow-borders-interactive-with-shadow [&_path]:shadow-details-contrast-on-bg-interactive group-disabled:text-ui-fg-disabled group-disabled:!bg-ui-bg-disabled group-disabled:!shadow-borders-base transition-fg h-[14px] w-[14px] rounded-[3px]">
<div
className={clx(
"text-ui-fg-on-inverted bg-ui-bg-base shadow-borders-base [&_path]:shadow-details-contrast-on-bg-interactive transition-fg h-[14px] w-[14px] rounded-[3px]",
"group-disabled:cursor-not-allowed group-disabled:opacity-50",
"group-focus-visible:!shadow-borders-interactive-with-focus",
"group-hover:group-enabled:group-data-[state=unchecked]:bg-ui-bg-base-hover",
"group-data-[state=checked]:bg-ui-bg-interactive group-data-[state=checked]:shadow-borders-interactive-with-shadow",
"group-data-[state=indeterminate]:bg-ui-bg-interactive group-data-[state=indeterminate]:shadow-borders-interactive-with-shadow"
)}
>
<Primitives.Indicator className="absolute inset-0 flex items-center justify-center">
{checked === "indeterminate" ? <MinusMini /> : <CheckMini />}
</Primitives.Indicator>
@@ -36,7 +36,7 @@ const Indicator = React.forwardRef<
>
<div
className={clx(
"bg-ui-bg-base shadow-details-contrast-on-bg-interactive group-disabled:bg-ui-fg-disabled h-1.5 w-1.5 rounded-full group-disabled:shadow-none"
"bg-ui-bg-base shadow-details-contrast-on-bg-interactive h-1.5 w-1.5 rounded-full"
)}
/>
</Primitives.Indicator>
@@ -60,10 +60,10 @@ const Item = React.forwardRef<
<div
className={clx(
"shadow-borders-base bg-ui-bg-base transition-fg flex h-[14px] w-[14px] items-center justify-center rounded-full",
"group-hover:bg-ui-bg-base-hover",
"group-hover:group-enabled:group-data-[state=unchecked]:bg-ui-bg-base-hover",
"group-data-[state=checked]:bg-ui-bg-interactive group-data-[state=checked]:shadow-borders-interactive-with-shadow",
"group-focus-visible:!shadow-borders-interactive-with-focus",
"group-disabled:!bg-ui-bg-disabled group-disabled:!shadow-borders-base"
"group-disabled:cursor-not-allowed group-disabled:opacity-50"
)}
>
<Indicator />
@@ -95,7 +95,10 @@ const ChoiceBox = React.forwardRef<
<Primitives.Item
ref={ref}
className={clx(
"shadow-borders-base bg-ui-bg-base focus-visible:shadow-borders-interactive-with-focus outline-none transition-fg disabled:bg-ui-bg-disabled group flex items-start gap-x-2 rounded-lg p-3 disabled:cursor-not-allowed data-[state=checked]:shadow-borders-interactive-with-shadow",
"shadow-borders-base bg-ui-bg-base focus-visible:shadow-borders-interactive-with-focus transition-fg group flex items-start gap-x-2 rounded-lg p-3 outline-none",
"hover:enabled:bg-ui-bg-base-hover",
"data-[state=checked]:shadow-borders-interactive-with-shadow",
"group-disabled:cursor-not-allowed group-disabled:opacity-50",
className
)}
{...props}
@@ -103,7 +106,12 @@ const ChoiceBox = React.forwardRef<
aria-describedby={descriptionId}
>
<div className="flex h-5 w-5 items-center justify-center">
<div className="shadow-borders-base bg-ui-bg-base group-data-[state=checked]:bg-ui-bg-interactive group-data-[state=checked]:shadow-borders-interactive-with-shadow transition-fg group-disabled:!bg-ui-bg-disabled group-hover:bg-ui-bg-base-hover group-disabled:!shadow-borders-base flex h-3.5 w-3.5 items-center justify-center rounded-full">
<div
className={clx(
"shadow-borders-base bg-ui-bg-base group-data-[state=checked]:bg-ui-bg-interactive group-data-[state=checked]:shadow-borders-interactive-with-shadow transition-fg flex h-3.5 w-3.5 items-center justify-center rounded-full",
"group-hover:group-enabled:group-data-[state=unchecked]:bg-ui-bg-base-hover"
)}
>
<Indicator />
</div>
</div>
@@ -117,7 +125,7 @@ const ChoiceBox = React.forwardRef<
{label}
</Label>
<Hint
className="txt-compact-medium text-ui-fg-subtle group-disabled:text-ui-fg-disabled text-left"
className="txt-small text-ui-fg-subtle group-disabled:text-ui-fg-disabled text-left"
id={descriptionId}
>
{description}
@@ -7,7 +7,7 @@ import * as React from "react"
import { clx } from "@/utils/clx"
const switchVariants = cva({
base: "bg-ui-bg-switch-off hover:bg-ui-bg-switch-off-hover data-[state=unchecked]:hover:after:bg-switch-off-hover-gradient before:shadow-details-switch-background focus-visible:shadow-details-switch-background-focus data-[state=checked]:bg-ui-bg-interactive disabled:!bg-ui-bg-disabled group relative inline-flex items-center rounded-full outline-none transition-all before:absolute before:inset-0 before:rounded-full before:content-[''] after:absolute after:inset-0 after:rounded-full after:content-[''] disabled:cursor-not-allowed",
base: "bg-ui-bg-switch-off hover:bg-ui-bg-switch-off-hover data-[state=unchecked]:hover:after:bg-switch-off-hover-gradient before:shadow-details-switch-background focus-visible:shadow-details-switch-background-focus data-[state=checked]:bg-ui-bg-interactive disabled:opacity-50 group relative inline-flex items-center rounded-full outline-none transition-all before:absolute before:inset-0 before:rounded-full before:content-[''] after:absolute after:inset-0 after:rounded-full after:content-[''] disabled:cursor-not-allowed",
variants: {
size: {
small: "h-[16px] w-[28px]",
@@ -20,7 +20,7 @@ const switchVariants = cva({
})
const thumbVariants = cva({
base: "bg-ui-fg-on-color shadow-details-switch-handle group-disabled:bg-ui-fg-disabled pointer-events-none h-[14px] w-[14px] rounded-full transition-all group-disabled:shadow-none",
base: "bg-ui-fg-on-color shadow-details-switch-handle pointer-events-none h-[14px] w-[14px] rounded-full transition-all",
variants: {
size: {
small:
@@ -1,39 +1,35 @@
import {
AdminCreateInventoryLocationLevelType,
AdminUpdateInventoryLocationLevelType,
} from "../../../validators"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { AdminBatchInventoryItemLocationsLevelType } from "../../../validators"
import { bulkCreateDeleteLevelsWorkflow } from "@medusajs/core-flows"
import { BatchMethodRequest } from "@medusajs/framework/types"
import { batchInventoryItemLevelsWorkflow } from "@medusajs/core-flows"
export const POST = async (
req: MedusaRequest<
BatchMethodRequest<
AdminCreateInventoryLocationLevelType,
AdminUpdateInventoryLocationLevelType
>
>,
res: MedusaResponse<{ inventory_item: {} }>
req: MedusaRequest<AdminBatchInventoryItemLocationsLevelType>,
res: MedusaResponse
) => {
const { id } = req.params
// TODO: Normalize workflow and response, and add support for updates
const workflow = bulkCreateDeleteLevelsWorkflow(req.scope)
await workflow.run({
const workflow = batchInventoryItemLevelsWorkflow(req.scope)
const output = await workflow.run({
input: {
deletes:
req.validatedBody.delete?.map((location_id) => ({
location_id,
inventory_item_id: id,
})) ?? [],
creates:
delete: req.validatedBody.delete ?? [],
create:
req.validatedBody.create?.map((c) => ({
...c,
inventory_item_id: id,
})) ?? [],
update:
req.validatedBody.update?.map((u) => ({
...u,
inventory_item_id: id,
})) ?? [],
force: req.validatedBody.force ?? false,
},
})
res.status(200).json({ inventory_item: {} })
res.status(200).json({
created: output.result.created,
updated: output.result.updated,
deleted: output.result.deleted,
})
}
@@ -0,0 +1,20 @@
import { batchInventoryItemLevelsWorkflow } from "@medusajs/core-flows"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
import { AdminBatchInventoryItemLevelsType } from "../../validators"
export const POST = async (
req: MedusaRequest<AdminBatchInventoryItemLevelsType>,
res: MedusaResponse
) => {
const body = req.validatedBody
const output = await batchInventoryItemLevelsWorkflow(req.scope).run({
input: body,
})
res.json({
created: output.result.created,
updated: output.result.updated,
deleted: output.result.deleted,
})
}
@@ -4,9 +4,10 @@ import {
} from "@medusajs/framework"
import { MiddlewareRoute, unlessPath } from "@medusajs/framework/http"
import { DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT } from "../../../utils/middlewares"
import { createBatchBody } from "../../utils/validators"
import * as QueryConfig from "./query-config"
import {
AdminBatchInventoryItemLevels,
AdminBatchInventoryItemLocationsLevel,
AdminCreateInventoryItem,
AdminCreateInventoryLocationLevel,
AdminGetInventoryItemParams,
@@ -49,6 +50,22 @@ export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [
),
],
},
{
method: ["POST"],
matcher: "/admin/inventory-items/batch",
bodyParser: {
sizeLimit: DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT,
},
middlewares: [validateAndTransformBody(AdminBatchInventoryItemLevels)],
},
{
method: ["POST"],
matcher: "/admin/inventory-items/location-levels/batch",
bodyParser: {
sizeLimit: DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT,
},
middlewares: [validateAndTransformBody(AdminBatchInventoryItemLevels)],
},
{
method: ["POST"],
matcher: "/admin/inventory-items/:id",
@@ -88,12 +105,7 @@ export const adminInventoryRoutesMiddlewares: MiddlewareRoute[] = [
sizeLimit: DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT,
},
middlewares: [
validateAndTransformBody(
createBatchBody(
AdminCreateInventoryLocationLevel,
AdminUpdateInventoryLocationLevel
)
),
validateAndTransformBody(AdminBatchInventoryItemLocationsLevel),
validateAndTransformQuery(
AdminGetInventoryLocationLevelParams,
QueryConfig.retrieveLocationLevelsTransformQueryConfig
@@ -75,6 +75,30 @@ export const AdminCreateInventoryLocationLevel = z
})
.strict()
export type AdminUpdateInventoryLocationLevelBatchType = z.infer<
typeof AdminUpdateInventoryLocationLevelBatch
>
export const AdminUpdateInventoryLocationLevelBatch = z
.object({
id: z.string().optional(),
location_id: z.string(),
stocked_quantity: z.number().min(0).optional(),
incoming_quantity: z.number().min(0).optional(),
})
.strict()
export type AdminBatchInventoryItemLocationsLevelType = z.infer<
typeof AdminBatchInventoryItemLocationsLevel
>
export const AdminBatchInventoryItemLocationsLevel = z.object({
create: z.array(AdminCreateInventoryLocationLevel).optional(),
update: z.array(AdminUpdateInventoryLocationLevelBatch).optional(),
delete: z.array(z.string()).optional(),
force: z.boolean().optional(),
})
export type AdminUpdateInventoryLocationLevelType = z.infer<
typeof AdminUpdateInventoryLocationLevel
>
@@ -129,3 +153,23 @@ export const AdminUpdateInventoryItem = z
metadata: z.record(z.unknown()).nullish(),
})
.strict()
const AdminBatchInventoryLocationLevel = z.object({
inventory_item_id: z.string(),
location_id: z.string(),
stocked_quantity: z.number().min(0).optional(),
incoming_quantity: z.number().min(0).optional(),
})
export const AdminBatchInventoryItemLevels = z
.object({
create: z.array(AdminBatchInventoryLocationLevel).optional(),
update: z.array(AdminBatchInventoryLocationLevel).optional(),
delete: z.array(z.string()).optional(),
force: z.boolean().optional(),
})
.strict()
export type AdminBatchInventoryItemLevelsType = z.infer<
typeof AdminBatchInventoryItemLevels
>
@@ -546,11 +546,11 @@ export default class InventoryModuleService
// @ts-ignore
async updateInventoryLevels(
updates: InventoryTypes.BulkUpdateInventoryLevelInput[],
updates: InventoryTypes.UpdateInventoryLevelInput[],
context?: Context
): Promise<InventoryTypes.InventoryLevelDTO[]>
async updateInventoryLevels(
updates: InventoryTypes.BulkUpdateInventoryLevelInput,
updates: InventoryTypes.UpdateInventoryLevelInput,
context?: Context
): Promise<InventoryTypes.InventoryLevelDTO>
@@ -558,8 +558,8 @@ export default class InventoryModuleService
@EmitEvents()
async updateInventoryLevels(
updates:
| InventoryTypes.BulkUpdateInventoryLevelInput[]
| InventoryTypes.BulkUpdateInventoryLevelInput,
| InventoryTypes.UpdateInventoryLevelInput[]
| InventoryTypes.UpdateInventoryLevelInput,
@MedusaContext() context: Context = {}
): Promise<
InventoryTypes.InventoryLevelDTO | InventoryTypes.InventoryLevelDTO[]
@@ -592,7 +592,7 @@ export default class InventoryModuleService
@InjectTransactionManager()
async updateInventoryLevels_(
updates: InventoryTypes.BulkUpdateInventoryLevelInput[],
updates: InventoryTypes.UpdateInventoryLevelInput[],
@MedusaContext() context: Context = {}
) {
const inventoryLevels = await this.ensureInventoryLevels(
@@ -611,16 +611,13 @@ export default class InventoryModuleService
return acc
}, new Map())
return await this.inventoryLevelService_.update(
updates.map((update) => {
const id = levelMap
.get(update.inventory_item_id)
.get(update.location_id)
const updatesWithIds = updates.map((update) => {
const id = levelMap.get(update.inventory_item_id).get(update.location_id)
return { id, ...update }
}),
context
)
return { id, ...update }
})
return await this.inventoryLevelService_.update(updatesWithIds, context)
}
/**
+44 -7
View File
@@ -5617,12 +5617,12 @@ __metadata:
autoprefixer: ^10.4.17
cmdk: ^0.2.0
date-fns: ^3.6.0
framer-motion: ^11.0.3
i18next: 23.7.11
i18next-browser-languagedetector: 7.2.0
i18next-http-backend: 2.4.2
lodash: ^4.17.21
match-sorter: ^6.3.4
motion: ^11.15.0
postcss: ^8.4.33
prettier: ^3.1.1
qs: ^6.12.0
@@ -20712,15 +20712,17 @@ __metadata:
languageName: node
linkType: hard
"framer-motion@npm:^11.0.3":
version: 11.1.8
resolution: "framer-motion@npm:11.1.8"
"framer-motion@npm:^11.15.0":
version: 11.15.0
resolution: "framer-motion@npm:11.15.0"
dependencies:
motion-dom: ^11.14.3
motion-utils: ^11.14.3
tslib: ^2.4.0
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0
react-dom: ^18.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
@@ -20728,7 +20730,7 @@ __metadata:
optional: true
react-dom:
optional: true
checksum: ff7073ca011936fcf0e4af54ff93824609cf03b556def5a69d21f44674c44ec0429a450b419bb4ae89d5b7af9d7b0e88ed57d3eff1ff668ed2a52b42a60375dd
checksum: 59f1c1eea09a5cbda346624a7d700bdb1ccff8a8528ed145009db974283064c3a4e55ca9eaaf4950494f254f6233c37634735b9bd8463b25ffeef624030894d6
languageName: node
linkType: hard
@@ -25290,6 +25292,41 @@ __metadata:
languageName: node
linkType: hard
"motion-dom@npm:^11.14.3":
version: 11.14.3
resolution: "motion-dom@npm:11.14.3"
checksum: 14989aba2981dcf618dc77d202ac35325366e645fd9e57c6942d88d0696263bbe7d0680da2e5f84e93339a67255bdbfebb8a4994a46584a661dd9a1e136fa7a1
languageName: node
linkType: hard
"motion-utils@npm:^11.14.3":
version: 11.14.3
resolution: "motion-utils@npm:11.14.3"
checksum: 7459bcb27311b72b416b2618cbfd56bad7d0fbec27736529e3f45a561fa78c43bf82e05338d9d9b765649b57d1c693821e83b30c6ba449d6f7f66c5245f072fb
languageName: node
linkType: hard
"motion@npm:^11.15.0":
version: 11.15.0
resolution: "motion@npm:11.15.0"
dependencies:
framer-motion: ^11.15.0
tslib: ^2.4.0
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
react:
optional: true
react-dom:
optional: true
checksum: fe10db69ad3ca7cb3fd2896d4cd6a79ca8080de16f5fdfcf82a6decd474423f4207b7a924dc7bfb405cffb36d7de0e13780f1a623287be354ced65c78a612c99
languageName: node
linkType: hard
"mri@npm:^1.1.0":
version: 1.2.0
resolution: "mri@npm:1.2.0"