diff --git a/.changeset/violet-weeks-pretend.md b/.changeset/violet-weeks-pretend.md new file mode 100644 index 0000000000000..aa39a4123bf29 --- /dev/null +++ b/.changeset/violet-weeks-pretend.md @@ -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 diff --git a/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts b/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts index 5661b45e2bc59..9a62749b3c3af 100644 --- a/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts +++ b/integration-tests/http/__tests__/inventory/admin/inventory.spec.ts @@ -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/location-levels/batch", () => { + let locationLevel1 + let locationLevel2 + + beforeEach(async () => { + 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 () => { - await api.post( + 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.`, }) }) diff --git a/packages/admin/dashboard/package.json b/packages/admin/dashboard/package.json index 15a2f03f4d314..2d91c23a6e333 100644 --- a/packages/admin/dashboard/package.json +++ b/packages/admin/dashboard/package.json @@ -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", diff --git a/packages/admin/dashboard/src/components/common/logo-box/avatar-box.tsx b/packages/admin/dashboard/src/components/common/logo-box/avatar-box.tsx index ab4c7fff93275..a9c4cfb62f652 100644 --- a/packages/admin/dashboard/src/components/common/logo-box/avatar-box.tsx +++ b/packages/admin/dashboard/src/components/common/logo-box/avatar-box.tsx @@ -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 ( {checked && ( { + return ( + + ) +} diff --git a/packages/admin/dashboard/src/components/common/thumbnail/thumbnail.tsx b/packages/admin/dashboard/src/components/common/thumbnail/thumbnail.tsx index ffd0002f12a7d..5e32de1cda5d3 100644 --- a/packages/admin/dashboard/src/components/common/thumbnail/thumbnail.tsx +++ b/packages/admin/dashboard/src/components/common/thumbnail/thumbnail.tsx @@ -11,7 +11,7 @@ export const Thumbnail = ({ src, alt, size = "base" }: ThumbnailProps) => { return (
)}
diff --git a/packages/admin/dashboard/src/components/data-grid/components/data-grid-duplicate-cell.tsx b/packages/admin/dashboard/src/components/data-grid/components/data-grid-duplicate-cell.tsx new file mode 100644 index 0000000000000..19e8218fef852 --- /dev/null +++ b/packages/admin/dashboard/src/components/data-grid/components/data-grid-duplicate-cell.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from "react" +import { useDataGridDuplicateCell } from "../hooks" + +interface DataGridDuplicateCellProps { + duplicateOf: string + children?: ReactNode | ((props: { value: TValue }) => ReactNode) +} +export const DataGridDuplicateCell = ({ + duplicateOf, + children, +}: DataGridDuplicateCellProps) => { + const { watchedValue } = useDataGridDuplicateCell({ duplicateOf }) + + return ( +
+ {typeof children === "function" + ? children({ value: watchedValue }) + : children} +
+ ) +} diff --git a/packages/admin/dashboard/src/components/data-grid/components/data-grid-readonly-cell.tsx b/packages/admin/dashboard/src/components/data-grid/components/data-grid-readonly-cell.tsx index bc00e80f899b9..cf842684ae0cc 100644 --- a/packages/admin/dashboard/src/components/data-grid/components/data-grid-readonly-cell.tsx +++ b/packages/admin/dashboard/src/components/data-grid/components/data-grid-readonly-cell.tsx @@ -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 = DataGridCellProps< - TData, - TValue -> & - PropsWithChildren +type DataGridReadonlyCellProps = PropsWithChildren< + DataGridCellProps +> & { + color?: "muted" | "normal" +} export const DataGridReadonlyCell = ({ context, + color = "muted", children, }: DataGridReadonlyCellProps) => { const { rowErrors } = useDataGridCellError({ context }) return ( -
- {children} +
+
{children}
) diff --git a/packages/admin/dashboard/src/components/data-grid/components/data-grid-root.tsx b/packages/admin/dashboard/src/components/data-grid/components/data-grid-root.tsx index d8d62536bb717..9dea7779b9f89 100644 --- a/packages/admin/dashboard/src/components/data-grid/components/data-grid-root.tsx +++ b/packages/admin/dashboard/src/components/data-grid/components/data-grid-root.tsx @@ -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[] @@ -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 = ( /** * 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) => { const containerRef = useRef(null) @@ -231,8 +232,13 @@ export const DataGridRoot = < } const matrix = useMemo( - () => new DataGridMatrix(flatRows, columns), - [flatRows, columns] + () => + new DataGridMatrix( + 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 = { rowIndex: number anchor: DataGridCoordinates | null onDragToFillStart: (e: React.MouseEvent) => void + multiColumnSelection: boolean } const DataGridCell = ({ @@ -795,6 +805,7 @@ const DataGridCell = ({ rowIndex, anchor, onDragToFillStart, + multiColumnSelection, }: DataGridCellProps) => { const coords: DataGridCoordinates = { row: rowIndex, @@ -828,7 +839,12 @@ const DataGridCell = ({ {isAnchor && (
)}
@@ -846,6 +862,7 @@ type DataGridRowProps = { flatColumns: Column[] anchor: DataGridCoordinates | null onDragToFillStart: (e: React.MouseEvent) => void + multiColumnSelection: boolean } const DataGridRow = ({ @@ -858,6 +875,7 @@ const DataGridRow = ({ flatColumns, anchor, onDragToFillStart, + multiColumnSelection, }: DataGridRowProps) => { const visibleCells = row.getVisibleCells() @@ -904,6 +922,7 @@ const DataGridRow = ({ rowIndex={rowIndex} anchor={anchor} onDragToFillStart={onDragToFillStart} + multiColumnSelection={multiColumnSelection} /> ) diff --git a/packages/admin/dashboard/src/components/data-grid/components/data-grid-toggleable-number-cell.tsx b/packages/admin/dashboard/src/components/data-grid/components/data-grid-toggleable-number-cell.tsx new file mode 100644 index 0000000000000..a9bc51e49ac06 --- /dev/null +++ b/packages/admin/dashboard/src/components/data-grid/components/data-grid-toggleable-number-cell.tsx @@ -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 = ({ + context, + disabledToggleTooltip, + ...rest +}: DataGridCellProps & { + min?: number + max?: number + placeholder?: string + disabledToggleTooltip?: string +}) => { + const { field, control, renderProps } = useDataGridCell({ + context, + }) + const errorProps = useDataGridCellError({ context }) + + const { container, input } = renderProps + + return ( + { + return ( + + } + > + + + ) + }} + /> + ) +} + +const OuterComponent = ({ + field, + inputProps, + isAnchor, + tooltip, +}: { + field: ControllerRenderProps + inputProps: InputProps + isAnchor: boolean + tooltip?: string +}) => { + const buttonRef = useRef(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 ( + +
+ +
+
+ ) +} + +const Inner = ({ + field, + inputProps, + placeholder, + ...props +}: { + field: ControllerRenderProps + 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 ( +
+ { + onBlur() + onInputBlur() + handleOnChange() + }} + onFocus={onFocus} + decimalsLimit={0} + autoComplete="off" + tabIndex={-1} + placeholder={!localValue.checked ? placeholder : undefined} + /> +
+ ) +} diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/index.ts b/packages/admin/dashboard/src/components/data-grid/hooks/index.ts index f03d7f838ef72..2307c30c866e5 100644 --- a/packages/admin/dashboard/src/components/data-grid/hooks/index.ts +++ b/packages/admin/dashboard/src/components/data-grid/hooks/index.ts @@ -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" diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell-handlers.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell-handlers.tsx index 3d9fd48e16c1a..1aee1dc2e2613 100644 --- a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell-handlers.tsx +++ b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell-handlers.tsx @@ -17,6 +17,7 @@ type UseDataGridCellHandlersOptions = { setDragEnd: (coords: DataGridCoordinates | null) => void setValue: UseFormSetValue execute: (command: DataGridUpdateCommand) => void + multiColumnSelection?: boolean } export const useDataGridCellHandlers = < @@ -36,6 +37,7 @@ export const useDataGridCellHandlers = < setDragEnd, setValue, execute, + multiColumnSelection, }: UseDataGridCellHandlersOptions) => { const getWrapperFocusHandler = useCallback( (coords: DataGridCoordinates) => { @@ -74,9 +76,9 @@ export const useDataGridCellHandlers = < return (_e: MouseEvent) => { /** * 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( diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell-snapshot.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell-snapshot.tsx index ffc3dcbf1a5c1..9f73327cc5509 100644 --- a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell-snapshot.tsx +++ b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell-snapshot.tsx @@ -15,9 +15,8 @@ export const useDataGridCellSnapshot = < matrix, form, }: UseDataGridCellSnapshotOptions) => { - const [snapshot, setSnapshot] = useState | null>( - null - ) + const [snapshot, setSnapshot] = + useState | null>(null) const { getValues, setValue } = form @@ -38,7 +37,18 @@ export const useDataGridCellSnapshot = < const value = getValues(field as Path) - 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] ) diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx index 3f6ee47b2e2ad..1fa693f2e68c9 100644 --- a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx +++ b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-cell.tsx @@ -123,16 +123,16 @@ export const useDataGridCell = ({ 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] ) diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-clipboard-events.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-clipboard-events.tsx index 301d578f66a55..d8170b48740fc 100644 --- a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-clipboard-events.tsx +++ b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-clipboard-events.tsx @@ -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) }, diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-duplicate-cell.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-duplicate-cell.tsx new file mode 100644 index 0000000000000..4244466c4aa4e --- /dev/null +++ b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-duplicate-cell.tsx @@ -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, + } +} diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-form-handlers.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-form-handlers.tsx index bee8c62bac50b..cf603950ab1a7 100644 --- a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-form-handlers.tsx +++ b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-form-handlers.tsx @@ -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 = { matrix: DataGridMatrix @@ -12,13 +18,13 @@ type UseDataGridFormHandlersOptions = { export const useDataGridFormHandlers = < TData, - TFieldValues extends FieldValues, + TFieldValues extends FieldValues >({ matrix, form, anchor, }: UseDataGridFormHandlersOptions) => { - const { getValues, setValue } = form + const { getValues, reset } = form const getSelectionValues = useCallback( (fields: string[]): PathValue>[] => { @@ -26,26 +32,28 @@ export const useDataGridFormHandlers = < return [] } + const allValues = getValues() + return fields.map((field) => { - return getValues(field as Path) - }) + return field.split(".").reduce((obj, key) => obj?.[key], allValues) + }) as PathValue>[] }, [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 - > - - setValue(field as Path, value, { - shouldDirty: true, - shouldTouch: true, - }) + const newValue = convertedValues[valueIndex] + + 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": diff --git a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx index 3e7da7b1a2a3e..9980db9aa5920 100644 --- a/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx +++ b/packages/admin/dashboard/src/components/data-grid/hooks/use-data-grid-keydown-event.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react" +import React, { useCallback } from "react" import type { FieldValues, Path, @@ -39,6 +39,7 @@ type UseDataGridKeydownEventOptions = { ) => PathValue>[] 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) => { const handleKeyboardNavigation = useCallback( (e: KeyboardEvent) => { @@ -218,6 +220,8 @@ export const useDataGridKeydownEvent = < return } + createSnapshot(anchor) + const current = getValues(field as Path) 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) + 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, 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( diff --git a/packages/admin/dashboard/src/components/data-grid/models/data-grid-bulk-update-command.ts b/packages/admin/dashboard/src/components/data-grid/models/data-grid-bulk-update-command.ts index 8c5355f10cf34..426b7ea4671fd 100644 --- a/packages/admin/dashboard/src/components/data-grid/models/data-grid-bulk-update-command.ts +++ b/packages/admin/dashboard/src/components/data-grid/models/data-grid-bulk-update-command.ts @@ -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) } } diff --git a/packages/admin/dashboard/src/components/data-grid/models/data-grid-matrix.ts b/packages/admin/dashboard/src/components/data-grid/models/data-grid-matrix.ts index 24e0d4d276fe8..bfaf31bda5387 100644 --- a/packages/admin/dashboard/src/components/data-grid/models/data-grid-matrix.ts +++ b/packages/admin/dashboard/src/components/data-grid/models/data-grid-matrix.ts @@ -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 { + private multiColumnSelection: boolean private cells: Grid public rowAccessors: (string | null)[] = [] public columnAccessors: (string | null)[] = [] - constructor(data: Row[], columns: ColumnDef[]) { + constructor( + data: Row[], + columns: ColumnDef[], + multiColumnSelection: boolean = false + ) { + this.multiColumnSelection = multiColumnSelection this.cells = this._populateCells(data, columns) this.rowAccessors = this._computeRowAccessors() @@ -64,17 +76,26 @@ export class DataGridMatrix { 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 { 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 - - return cell.col === col && cell.row >= startRow && cell.row <= endRow + 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.row >= startRow && + cell.row <= endRow && + cell.col >= startCol && + cell.col <= endCol + ) } toggleColumn(col: number, enabled: boolean) { @@ -385,4 +418,4 @@ export class DataGridMatrix { return cells } -} \ No newline at end of file +} diff --git a/packages/admin/dashboard/src/components/data-grid/types.ts b/packages/admin/dashboard/src/components/data-grid/types.ts index 3f59ecbb70f45..8ddc4a419d2f6 100644 --- a/packages/admin/dashboard/src/components/data-grid/types.ts +++ b/packages/admin/dashboard/src/components/data-grid/types.ts @@ -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> @@ -160,3 +164,9 @@ export type GridColumnOption = { checked: boolean disabled: boolean } + +export type DataGridToggleableNumber = { + quantity: number | string + checked: boolean + disabledToggle: boolean +} diff --git a/packages/admin/dashboard/src/components/inputs/chip-input/chip-input.tsx b/packages/admin/dashboard/src/components/inputs/chip-input/chip-input.tsx index cd3e21a278faf..ce942d4fe6340 100644 --- a/packages/admin/dashboard/src/components/inputs/chip-input/chip-input.tsx +++ b/packages/admin/dashboard/src/components/inputs/chip-input/chip-input.tsx @@ -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, diff --git a/packages/admin/dashboard/src/components/layout/shell/shell.tsx b/packages/admin/dashboard/src/components/layout/shell/shell.tsx index 26c7603cb570c..df53453061051 100644 --- a/packages/admin/dashboard/src/components/layout/shell/shell.tsx +++ b/packages/admin/dashboard/src/components/layout/shell/shell.tsx @@ -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 ( -
+
+
{children} {children}
-
+
@@ -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 + + if (loading) { + timeout = setTimeout(() => { + setShowBar(true) + }, 200) + } else { + setShowBar(false) + } + + return () => { + clearTimeout(timeout) + } + }, [loading]) + + return ( +
+ {showBar ? : null} +
+ ) +} + const Gutter = ({ children }: PropsWithChildren) => { return (
diff --git a/packages/admin/dashboard/src/hooks/api/inventory.tsx b/packages/admin/dashboard/src/hooks/api/inventory.tsx index 93f7c867c8c23..de8e7bd48c99b 100644 --- a/packages/admin/dashboard/src/hooks/api/inventory.tsx +++ b/packages/admin/dashboard/src/hooks/api/inventory.tsx @@ -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, + 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, + }) +} diff --git a/packages/admin/dashboard/src/hooks/api/products.tsx b/packages/admin/dashboard/src/hooks/api/products.tsx index edb4a68b38135..eee11f83d89c1 100644 --- a/packages/admin/dashboard/src/hooks/api/products.tsx +++ b/packages/admin/dashboard/src/hooks/api/products.tsx @@ -110,9 +110,14 @@ export const useProductVariant = ( export const useProductVariants = ( productId: string, - query?: Record, + query?: HttpTypes.AdminProductVariantParams, options?: Omit< - UseQueryOptions, + UseQueryOptions< + HttpTypes.AdminProductVariantListResponse, + FetchError, + HttpTypes.AdminProductVariantListResponse, + QueryKey + >, "queryFn" | "queryKey" > ) => { diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 9a5b09d676186..698a73917ddcb 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -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 }, diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index a6cc4ab689b5f..f4192da83f311 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -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": { diff --git a/packages/admin/dashboard/src/providers/router-provider/route-map.tsx b/packages/admin/dashboard/src/providers/router-provider/route-map.tsx index 0862cb3371199..0d47e75cbc5ef 100644 --- a/packages/admin/dashboard/src/providers/router-provider/route-map.tsx +++ b/packages/admin/dashboard/src/providers/router-provider/route-map.tsx @@ -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"), + }, ], }, { diff --git a/packages/admin/dashboard/src/routes/customers/customer-detail/customer-detail.tsx b/packages/admin/dashboard/src/routes/customers/customer-detail/customer-detail.tsx index f0ba697228e2e..4c77a5d99cb8d 100644 --- a/packages/admin/dashboard/src/routes/customers/customer-detail/customer-detail.tsx +++ b/packages/admin/dashboard/src/routes/customers/customer-detail/customer-detail.tsx @@ -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() diff --git a/packages/admin/dashboard/src/routes/inventory/common/constants.ts b/packages/admin/dashboard/src/routes/inventory/common/constants.ts new file mode 100644 index 0000000000000..81cf300172386 --- /dev/null +++ b/packages/admin/dashboard/src/routes/inventory/common/constants.ts @@ -0,0 +1 @@ +export const INVENTORY_ITEM_IDS_KEY = "inventory_item_ids" diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/inventory-item-location-levels.tsx b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/inventory-item-location-levels.tsx index 3ca7071f6586b..8904d645bbfff 100644 --- a/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/inventory-item-location-levels.tsx +++ b/packages/admin/dashboard/src/routes/inventory/inventory-detail/components/inventory-item-location-levels.tsx @@ -1,8 +1,8 @@ +import { HttpTypes } from "@medusajs/types" 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 { 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 ( - +
{t("inventory.locationLevels")} + + +
+ + + + ) +} + +function getDefaultValues( + items: HttpTypes.AdminInventoryItem[], + locations: HttpTypes.AdminStockLocation[] +): DefaultValues { + 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), + } +} diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-stock/hooks/use-inventory-stock-columns.tsx b/packages/admin/dashboard/src/routes/inventory/inventory-stock/hooks/use-inventory-stock-columns.tsx new file mode 100644 index 0000000000000..96643b29a610c --- /dev/null +++ b/packages/admin/dashboard/src/routes/inventory/inventory-stock/hooks/use-inventory-stock-columns.tsx @@ -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 ( + + {item.title || "-"} + + ) + }, + disableHiding: true, + }), + helper.column({ + id: "sku", + name: "SKU", + header: "SKU", + cell: (context) => { + const item = context.row.original + + return ( + + {item.sku || "-"} + + ) + }, + 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 ( + + ) + }, + }) + ), + ], + [locations, t] + ) +} diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-stock/index.ts b/packages/admin/dashboard/src/routes/inventory/inventory-stock/index.ts new file mode 100644 index 0000000000000..f629a40198ef6 --- /dev/null +++ b/packages/admin/dashboard/src/routes/inventory/inventory-stock/index.ts @@ -0,0 +1 @@ +export { InventoryStock as Component } from "./inventory-stock" diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-stock/inventory-stock.tsx b/packages/admin/dashboard/src/routes/inventory/inventory-stock/inventory-stock.tsx new file mode 100644 index 0000000000000..4abc69310fac3 --- /dev/null +++ b/packages/admin/dashboard/src/routes/inventory/inventory-stock/inventory-stock.tsx @@ -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 ( + + + {t("inventory.stock.title")} + + + {t("inventory.stock.description")} + + {ready && ( + + )} + + ) +} diff --git a/packages/admin/dashboard/src/routes/inventory/inventory-stock/schema.ts b/packages/admin/dashboard/src/routes/inventory/inventory-stock/schema.ts new file mode 100644 index 0000000000000..ebaaa63c68a05 --- /dev/null +++ b/packages/admin/dashboard/src/routes/inventory/inventory-stock/schema.ts @@ -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 +export type InventoryItemSchema = z.infer +export type InventoryStockSchema = z.infer diff --git a/packages/admin/dashboard/src/routes/invite/invite.tsx b/packages/admin/dashboard/src/routes/invite/invite.tsx index 5535a0ae568a6..69152d6c7a462 100644 --- a/packages/admin/dashboard/src/routes/invite/invite.tsx +++ b/packages/admin/dashboard/src/routes/invite/invite.tsx @@ -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" diff --git a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx index 856e4ad93957b..282bfbdd3cd4c 100644 --- a/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx +++ b/packages/admin/dashboard/src/routes/locations/common/components/shipping-option-price-cell/shipping-option-price-cell.tsx @@ -130,7 +130,7 @@ const OuterComponent = ({ return (
- + {entity.title}
@@ -49,7 +49,7 @@ export const usePriceListGridColumns = ({ } return ( - +
{entity.title}
diff --git a/packages/admin/dashboard/src/routes/products/common/constants.ts b/packages/admin/dashboard/src/routes/products/common/constants.ts new file mode 100644 index 0000000000000..7d1a0b103f519 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/common/constants.ts @@ -0,0 +1 @@ +export const PRODUCT_VARIANT_IDS_KEY = "product_variant_ids" diff --git a/packages/admin/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx b/packages/admin/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx index 2ee691533728f..4d0a89a0b08ee 100644 --- a/packages/admin/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx +++ b/packages/admin/dashboard/src/routes/products/product-detail/components/product-variant-section/product-variant-section.tsx @@ -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({}) + 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: , }, + { + label: t("inventory.stock.action"), + to: `stock`, + icon: , + }, ], }, ]} @@ -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", + }, + ]} /> ) diff --git a/packages/admin/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx b/packages/admin/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx index ca5fb72aa6899..3c6994645e796 100644 --- a/packages/admin/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx +++ b/packages/admin/dashboard/src/routes/products/product-detail/components/product-variant-section/use-variant-table-columns.tsx @@ -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: , }, - { - label: t("actions.delete"), - onClick: handleDelete, - icon: , - }, 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: , + }, + ], + }, ]} /> ) @@ -145,6 +149,34 @@ export const useProductVariantTableColumns = ( return useMemo( () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), columnHelper.accessor("title", { header: () => (
diff --git a/packages/admin/dashboard/src/routes/products/product-stock/components/product-stock-form/index.ts b/packages/admin/dashboard/src/routes/products/product-stock/components/product-stock-form/index.ts new file mode 100644 index 0000000000000..d65f902d56e22 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-stock/components/product-stock-form/index.ts @@ -0,0 +1 @@ +export * from "./product-stock-form" diff --git a/packages/admin/dashboard/src/routes/products/product-stock/components/product-stock-form/product-stock-form.tsx b/packages/admin/dashboard/src/routes/products/product-stock/components/product-stock-form/product-stock-form.tsx new file mode 100644 index 0000000000000..f3f83c3c90dab --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-stock/components/product-stock-form/product-stock-form.tsx @@ -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({ + 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 ( + + + + + setCloseOnEscape(!editing)} + disableInteractions={isPending || isPromptOpen} + multiColumnSelection={true} + /> + + +
+ + + + +
+
+
+
+ ) +} + +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 { + 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 + ) + + variantAcc[variant.id] = { inventory_items: inventoryItems || {} } + return variantAcc + }, {} as Record), + } +} diff --git a/packages/admin/dashboard/src/routes/products/product-stock/hooks/use-product-stock-columns.tsx b/packages/admin/dashboard/src/routes/products/product-stock/hooks/use-product-stock-columns.tsx new file mode 100644 index 0000000000000..31515dbe2d8a3 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-stock/hooks/use-product-stock-columns.tsx @@ -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 = {} +) => { + 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 ( + +
+ + {item.title || "-"} +
+
+ ) + } + + const { isDisabled, item: disabledItem } = getIsDisabled(item) + + if (isDisabled) { + return ( + +
+ + {item.inventory?.title || "-"} + + + + +
+
+ ) + } + + return ( + + {item.inventory?.title || "-"} + + ) + }, + disableHiding: true, + }), + helper.column({ + id: "sku", + name: "SKU", + header: "SKU", + cell: (context) => { + const item = context.row.original + + if (isProductVariant(item)) { + return ( + + {item.sku || "-"} + + ) + } + + const { isDisabled } = getIsDisabled(item) + + if (isDisabled) { + return ( + + + {item.inventory?.sku || "-"} + + + ) + } + + return ( + + {item.inventory?.sku || "-"} + + ) + }, + 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 + } + + const { isDisabled, item: disabledItem } = getIsDisabled(item) + + if (isDisabled) { + return ( + + {({ value }) => { + const { checked, quantity } = value as { + checked: boolean + quantity: number | string + } + + return ( +
+ + + {quantity} + +
+ ) + }} +
+ ) + } + + return ( + + ) + }, + }) + ), + ], + [locations, getIsDisabled, t] + ) +} diff --git a/packages/admin/dashboard/src/routes/products/product-stock/index.ts b/packages/admin/dashboard/src/routes/products/product-stock/index.ts new file mode 100644 index 0000000000000..ddae1ead0def1 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-stock/index.ts @@ -0,0 +1,2 @@ +export { productStockLoader as loader } from "./loader" +export { ProductStock as Component } from "./product-stock" diff --git a/packages/admin/dashboard/src/routes/products/product-stock/loader.ts b/packages/admin/dashboard/src/routes/products/product-stock/loader.ts new file mode 100644 index 0000000000000..d64c9e349974b --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-stock/loader.ts @@ -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, + }) +} diff --git a/packages/admin/dashboard/src/routes/products/product-stock/product-stock.tsx b/packages/admin/dashboard/src/routes/products/product-stock/product-stock.tsx new file mode 100644 index 0000000000000..f3b3f1ac4add1 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-stock/product-stock.tsx @@ -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> + + /** + * 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>() + + 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 ( +
+
+ + {isLoading ? : null} + +
+ + + {t("products.stock.heading")} + + + {t("products.stock.description")} + + }> + + {(data: { + variants: HttpTypes.AdminProductVariant[] + locations: HttpTypes.AdminStockLocation[] + }) => { + return ( + + ) + }} + + + +
+ ) +} + +const ProductStockFallback = () => { + return ( +
+
+
+ +
+
+ []} + /> +
+
+ + +
+
+
+ ) +} diff --git a/packages/admin/dashboard/src/routes/products/product-stock/schema.ts b/packages/admin/dashboard/src/routes/products/product-stock/schema.ts new file mode 100644 index 0000000000000..d8f7afd3d99a9 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-stock/schema.ts @@ -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 diff --git a/packages/admin/dashboard/src/routes/products/product-stock/utils.ts b/packages/admin/dashboard/src/routes/products/product-stock/utils.ts new file mode 100644 index 0000000000000..0a0ea3fe621e3 --- /dev/null +++ b/packages/admin/dashboard/src/routes/products/product-stock/utils.ts @@ -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 = {} + const disabled: Record = + {} + + 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 +} diff --git a/packages/admin/dashboard/src/routes/workflow-executions/workflow-execution-detail/components/workflow-execution-timeline-section/workflow-execution-timeline-section.tsx b/packages/admin/dashboard/src/routes/workflow-executions/workflow-execution-detail/components/workflow-execution-timeline-section/workflow-execution-timeline-section.tsx index 8fab0fc8591d3..e0c3fe5cf6816 100644 --- a/packages/admin/dashboard/src/routes/workflow-executions/workflow-execution-detail/components/workflow-execution-timeline-section/workflow-execution-timeline-section.tsx +++ b/packages/admin/dashboard/src/routes/workflow-executions/workflow-execution-detail/components/workflow-execution-timeline-section/workflow-execution-timeline-section.tsx @@ -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"] diff --git a/packages/core/core-flows/src/inventory/steps/create-inventory-levels.ts b/packages/core/core-flows/src/inventory/steps/create-inventory-levels.ts index 4daccd83ee6d5..e4050c258247b 100644 --- a/packages/core/core-flows/src/inventory/steps/create-inventory-levels.ts +++ b/packages/core/core-flows/src/inventory/steps/create-inventory-levels.ts @@ -11,7 +11,6 @@ export const createInventoryLevelsStep = createStep( createInventoryLevelsStepId, async (data: InventoryTypes.CreateInventoryLevelInput[], { container }) => { const service = container.resolve(Modules.INVENTORY) - const inventoryLevels = await service.createInventoryLevels(data) return new StepResponse( inventoryLevels, diff --git a/packages/core/core-flows/src/inventory/steps/update-inventory-levels.ts b/packages/core/core-flows/src/inventory/steps/update-inventory-levels.ts index b2dc65232a88f..6233bd362502b 100644 --- a/packages/core/core-flows/src/inventory/steps/update-inventory-levels.ts +++ b/packages/core/core-flows/src/inventory/steps/update-inventory-levels.ts @@ -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[] ) } ) diff --git a/packages/core/core-flows/src/inventory/workflows/batch-inventory-item-levels.ts b/packages/core/core-flows/src/inventory/workflows/batch-inventory-item-levels.ts new file mode 100644 index 0000000000000..13ae45113620d --- /dev/null +++ b/packages/core/core-flows/src/inventory/workflows/batch-inventory-item-levels.ts @@ -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) => { + 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, + } + }) + ) + } +) diff --git a/packages/core/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts b/packages/core/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts index e911e8adf9465..546cfdddbf866 100644 --- a/packages/core/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts +++ b/packages/core/core-flows/src/inventory/workflows/bulk-create-delete-levels.ts @@ -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, diff --git a/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts b/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts index 1416929e91888..8e81007f42605 100644 --- a/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts +++ b/packages/core/core-flows/src/inventory/workflows/delete-inventory-levels.ts @@ -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 ${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 ${stockLocationIds} because there are stocked or reserved items at the locations` + `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) => { + (input: WorkflowData) => { + 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) diff --git a/packages/core/core-flows/src/inventory/workflows/index.ts b/packages/core/core-flows/src/inventory/workflows/index.ts index 5d5c413e5dba8..e5cc70b6adb2e 100644 --- a/packages/core/core-flows/src/inventory/workflows/index.ts +++ b/packages/core/core-flows/src/inventory/workflows/index.ts @@ -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" diff --git a/packages/core/core-flows/src/inventory/workflows/update-inventory-levels.ts b/packages/core/core-flows/src/inventory/workflows/update-inventory-levels.ts index 29a0926c67d8f..f4492f2cb7171 100644 --- a/packages/core/core-flows/src/inventory/workflows/update-inventory-levels.ts +++ b/packages/core/core-flows/src/inventory/workflows/update-inventory-levels.ts @@ -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" diff --git a/packages/core/js-sdk/src/admin/inventory-item.ts b/packages/core/js-sdk/src/admin/inventory-item.ts index 8a03c2a74b7c8..ffb6ebbba018e 100644 --- a/packages/core/js-sdk/src/admin/inventory-item.ts +++ b/packages/core/js-sdk/src/admin/inventory-item.ts @@ -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( + return await this.client.fetch( `/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( + `/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( + `/admin/inventory-items/location-levels/batch`, + { + method: "POST", + headers, + body, + } + ) + } } diff --git a/packages/core/types/src/http/inventory-level/admin/payloads.ts b/packages/core/types/src/http/inventory-level/admin/payloads.ts index eb576aa29655e..725f80c04b787 100644 --- a/packages/core/types/src/http/inventory-level/admin/payloads.ts +++ b/packages/core/types/src/http/inventory-level/admin/payloads.ts @@ -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. + */ + 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. */ - create?: AdminCreateInventoryLevel[] + 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 } diff --git a/packages/core/types/src/http/inventory-level/admin/responses.ts b/packages/core/types/src/http/inventory-level/admin/responses.ts index 6a05d8e117b7e..409cd4f327615 100644 --- a/packages/core/types/src/http/inventory-level/admin/responses.ts +++ b/packages/core/types/src/http/inventory-level/admin/responses.ts @@ -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 {} diff --git a/packages/core/types/src/http/product/admin/entitites.ts b/packages/core/types/src/http/product/admin/entitites.ts index 53e07b5506b46..c6533552ecab1 100644 --- a/packages/core/types/src/http/product/admin/entitites.ts +++ b/packages/core/types/src/http/product/admin/entitites.ts @@ -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 { /** diff --git a/packages/core/types/src/inventory/mutations/inventory-level.ts b/packages/core/types/src/inventory/mutations/inventory-level.ts index 797d82ed46955..1d0e38cfb790d 100644 --- a/packages/core/types/src/inventory/mutations/inventory-level.ts +++ b/packages/core/types/src/inventory/mutations/inventory-level.ts @@ -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 +} diff --git a/packages/core/types/src/inventory/service.ts b/packages/core/types/src/inventory/service.ts index fab3faf2f194e..4cbaca41cb6f6 100644 --- a/packages/core/types/src/inventory/service.ts +++ b/packages/core/types/src/inventory/service.ts @@ -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} The updated inventory levels. * @@ -626,14 +626,14 @@ export interface IInventoryService extends IModuleService { * ]) */ updateInventoryLevels( - updates: BulkUpdateInventoryLevelInput[], + updates: UpdateInventoryLevelInput[], context?: Context ): Promise /** * 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} The updated inventory level. * @@ -646,7 +646,7 @@ export interface IInventoryService extends IModuleService { * }) */ updateInventoryLevels( - updates: BulkUpdateInventoryLevelInput, + updates: UpdateInventoryLevelInput, context?: Context ): Promise @@ -1058,7 +1058,7 @@ export interface IInventoryService extends IModuleService { ): Promise /** - * + * * @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. diff --git a/packages/design-system/ui/src/components/checkbox/checkbox.tsx b/packages/design-system/ui/src/components/checkbox/checkbox.tsx index a26d2e9626f90..2136906986112 100644 --- a/packages/design-system/ui/src/components/checkbox/checkbox.tsx +++ b/packages/design-system/ui/src/components/checkbox/checkbox.tsx @@ -23,7 +23,16 @@ const Checkbox = React.forwardRef< className )} > -
+
{checked === "indeterminate" ? : } diff --git a/packages/design-system/ui/src/components/radio-group/radio-group.tsx b/packages/design-system/ui/src/components/radio-group/radio-group.tsx index 1133e9b11f133..e996cc712dafc 100644 --- a/packages/design-system/ui/src/components/radio-group/radio-group.tsx +++ b/packages/design-system/ui/src/components/radio-group/radio-group.tsx @@ -36,7 +36,7 @@ const Indicator = React.forwardRef< >
@@ -60,10 +60,10 @@ const Item = React.forwardRef<
@@ -95,7 +95,10 @@ const ChoiceBox = React.forwardRef<
-
+
@@ -117,7 +125,7 @@ const ChoiceBox = React.forwardRef< {label} {description} diff --git a/packages/design-system/ui/src/components/switch/switch.tsx b/packages/design-system/ui/src/components/switch/switch.tsx index 2a682023719b7..539c5d4e3af06 100644 --- a/packages/design-system/ui/src/components/switch/switch.tsx +++ b/packages/design-system/ui/src/components/switch/switch.tsx @@ -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: diff --git a/packages/medusa/src/api/admin/inventory-items/[id]/location-levels/batch/route.ts b/packages/medusa/src/api/admin/inventory-items/[id]/location-levels/batch/route.ts index 49c99f1a5b04c..e22a43827e34e 100644 --- a/packages/medusa/src/api/admin/inventory-items/[id]/location-levels/batch/route.ts +++ b/packages/medusa/src/api/admin/inventory-items/[id]/location-levels/batch/route.ts @@ -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, + 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, + }) } diff --git a/packages/medusa/src/api/admin/inventory-items/location-levels/batch/route.ts b/packages/medusa/src/api/admin/inventory-items/location-levels/batch/route.ts new file mode 100644 index 0000000000000..8b4f1b27540e6 --- /dev/null +++ b/packages/medusa/src/api/admin/inventory-items/location-levels/batch/route.ts @@ -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, + 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, + }) +} diff --git a/packages/medusa/src/api/admin/inventory-items/middlewares.ts b/packages/medusa/src/api/admin/inventory-items/middlewares.ts index 8394289723e5a..89d77ddd84a05 100644 --- a/packages/medusa/src/api/admin/inventory-items/middlewares.ts +++ b/packages/medusa/src/api/admin/inventory-items/middlewares.ts @@ -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 diff --git a/packages/medusa/src/api/admin/inventory-items/validators.ts b/packages/medusa/src/api/admin/inventory-items/validators.ts index 2b522f15a6117..8837580bbbfe9 100644 --- a/packages/medusa/src/api/admin/inventory-items/validators.ts +++ b/packages/medusa/src/api/admin/inventory-items/validators.ts @@ -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 +> diff --git a/packages/modules/inventory/src/services/inventory-module.ts b/packages/modules/inventory/src/services/inventory-module.ts index 8804b352c1d71..3fbac62d12c23 100644 --- a/packages/modules/inventory/src/services/inventory-module.ts +++ b/packages/modules/inventory/src/services/inventory-module.ts @@ -546,11 +546,11 @@ export default class InventoryModuleService // @ts-ignore async updateInventoryLevels( - updates: InventoryTypes.BulkUpdateInventoryLevelInput[], + updates: InventoryTypes.UpdateInventoryLevelInput[], context?: Context ): Promise async updateInventoryLevels( - updates: InventoryTypes.BulkUpdateInventoryLevelInput, + updates: InventoryTypes.UpdateInventoryLevelInput, context?: Context ): Promise @@ -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) } /** diff --git a/yarn.lock b/yarn.lock index 64271f6071b1f..834648f0758e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"