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