feat(admin-ui, medusa-js, medusa-react, medusa): Multiwarehousing UI (#3403)

* add "get-variant" endpoint

* import from a different place

* fix unit test

* add changeset

* inventory management for orders

* add changeset

* initial create-fulfillment

* add changeset

* type oas and admin

* Move inv. creation and listing from admin repo

* Fix location editing bug (CORE-1216)

* Fix default warehouse on inventory table view

* remove actions from each table line

* Use feature flag hook instead of context directly

* remove manage inventory action if inventory management is not enabled

* Address review comments

* fix queries made when inventorymodules are disabled

* variant form changes for feature enabled

* move exclamation icon into warning icon

* ensure queries are not run unless feature is enabled for create-fulfillment

---------

Co-authored-by: Philip Korsholm <philip.korsholm@hotmail.com>
Co-authored-by: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com>
This commit is contained in:
Rares Stefan
2023-03-08 16:08:56 +01:00
committed by GitHub
parent 53eda215e0
commit 57d7728dd9
49 changed files with 3922 additions and 722 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/admin-ui": patch
---
add location support in fulfillment modal
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
feat(medusa): add get-variant endpoint
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/admin-ui": patch
---
Add order allocation to admin ui
@@ -0,0 +1,24 @@
import IconProps from "../types/icon-type"
import React from "react"
const CircleQuarterSolid: React.FC<IconProps> = ({
size = "24",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<circle cx="10" cy="10" r="7.25" stroke={color} strokeWidth="1.5" />
<path d="M15 10C15 7.23858 12.7614 5 10 5V10H15Z" fill={color} />
</svg>
)
}
export default CircleQuarterSolid
@@ -1,6 +1,45 @@
import React, { FC } from "react"
import { FC } from "react"
import IconProps from "./types/icon-type"
type WarningCircleIconProps = IconProps & {
fillType?: "solid" | "outline"
}
const WarningCircleIcon: FC<WarningCircleIconProps> = ({
fillType = "outline",
...attributes
}) => {
if (fillType === "outline") {
return <WarningCircle {...attributes} />
} else {
return <ExclamationCircle {...attributes} />
}
}
const ExclamationCircle: FC<IconProps> = ({
size = "24",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18 10C18 12.1217 17.1571 14.1566 15.6569 15.6569C14.1566 17.1571 12.1217 18 10 18C7.87827 18 5.84344 17.1571 4.34315 15.6569C2.84285 14.1566 2 12.1217 2 10C2 7.87827 2.84285 5.84344 4.34315 4.34315C5.84344 2.84285 7.87827 2 10 2C12.1217 2 14.1566 2.84285 15.6569 4.34315C17.1571 5.84344 18 7.87827 18 10ZM10 5C10.1989 5 10.3897 5.07902 10.5303 5.21967C10.671 5.36032 10.75 5.55109 10.75 5.75V10.25C10.75 10.4489 10.671 10.6397 10.5303 10.7803C10.3897 10.921 10.1989 11 10 11C9.80109 11 9.61032 10.921 9.46967 10.7803C9.32902 10.6397 9.25 10.4489 9.25 10.25V5.75C9.25 5.55109 9.32902 5.36032 9.46967 5.21967C9.61032 5.07902 9.80109 5 10 5ZM10 15C10.2652 15 10.5196 14.8946 10.7071 14.7071C10.8946 14.5196 11 14.2652 11 14C11 13.7348 10.8946 13.4804 10.7071 13.2929C10.5196 13.1054 10.2652 13 10 13C9.73478 13 9.48043 13.1054 9.29289 13.2929C9.10536 13.4804 9 13.7348 9 14C9 14.2652 9.10536 14.5196 9.29289 14.7071C9.48043 14.8946 9.73478 15 10 15Z"
fill={color}
/>
</svg>
)
}
const WarningCircle: FC<IconProps> = (props) => {
const { fill, size, ...attributes } = props
const line = fill || "#111827"
@@ -39,4 +78,4 @@ const WarningCircle: FC<IconProps> = (props) => {
)
}
export default WarningCircle
export default WarningCircleIcon
@@ -1,4 +1,4 @@
import clsx from "clsx"
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
import React, {
ChangeEventHandler,
FocusEventHandler,
@@ -6,10 +6,11 @@ import React, {
useImperativeHandle,
useRef,
} from "react"
import InputError from "../../atoms/input-error"
import MinusIcon from "../../fundamentals/icons/minus-icon"
import PlusIcon from "../../fundamentals/icons/plus-icon"
import InputHeader, { InputHeaderProps } from "../../fundamentals/input-header"
import clsx from "clsx"
export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
InputHeaderProps & {
@@ -21,9 +22,11 @@ export type InputProps = Omit<React.ComponentPropsWithRef<"input">, "prefix"> &
onFocus?: FocusEventHandler<HTMLInputElement>
errors?: { [x: string]: unknown }
prefix?: React.ReactNode
suffix?: React.ReactNode
props?: React.HTMLAttributes<HTMLDivElement>
}
// eslint-disable-next-line react/display-name
const InputField = React.forwardRef<HTMLInputElement, InputProps>(
(
{
@@ -39,6 +42,7 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
tooltipContent,
tooltip,
prefix,
suffix,
errors,
props,
className,
@@ -89,9 +93,9 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
)}
<div
className={clsx(
"w-full flex items-center bg-grey-5 border border-gray-20 px-small py-xsmall rounded-rounded focus-within:shadow-input focus-within:border-violet-60",
"bg-grey-5 border-gray-20 px-small py-xsmall rounded-rounded focus-within:shadow-input focus-within:border-violet-60 flex w-full items-center border",
{
"border-rose-50 focus-within:shadow-cta focus-within:shadow-rose-60/10 focus-within:border-rose-50":
"focus-within:shadow-cta focus-within:shadow-rose-60/10 border-rose-50 focus-within:border-rose-50":
errors && name && errors[name],
},
small ? "h-8" : "h-10"
@@ -102,7 +106,7 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
) : null}
<input
className={clsx(
"bg-transparent outline-none outline-0 w-full remove-number-spinner leading-base text-grey-90 font-normal caret-violet-60 placeholder-grey-40",
"remove-number-spinner leading-base text-grey-90 caret-violet-60 placeholder-grey-40 w-full bg-transparent font-normal outline-none outline-0",
{ "text-small": small, "pt-[1px]": small }
)}
ref={inputRef}
@@ -114,11 +118,14 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
required={required}
{...fieldProps}
/>
{suffix ? (
<span className="mx-2xsmall text-grey-40">{suffix}</span>
) : null}
{deletable && (
<button
onClick={onDelete}
className="flex items-center justify-center w-4 h-4 pb-px ml-2 outline-none cursor-pointer text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft"
className="text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft ml-2 flex h-4 w-4 cursor-pointer items-center justify-center pb-px outline-none"
type="button"
>
&times;
@@ -126,11 +133,11 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
)}
{fieldProps.type === "number" && (
<div className="flex items-center self-end h-full">
<div className="flex h-full items-center self-end">
<button
onClick={onNumberDecrement}
onMouseDown={(e) => e.preventDefault()}
className="w-4 h-4 mr-2 outline-none cursor-pointer text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft"
className="text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft mr-2 h-4 w-4 cursor-pointer outline-none"
type="button"
>
<MinusIcon size={16} />
@@ -138,7 +145,7 @@ const InputField = React.forwardRef<HTMLInputElement, InputProps>(
<button
onMouseDown={(e) => e.preventDefault()}
onClick={onNumberIncrement}
className="w-4 h-4 outline-none cursor-pointer text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft"
className="text-grey-50 hover:bg-grey-10 focus:bg-grey-20 rounded-soft h-4 w-4 cursor-pointer outline-none"
type="button"
>
<PlusIcon size={16} />
@@ -3,6 +3,7 @@ import * as Portal from "@radix-ui/react-portal"
import clsx from "clsx"
import React from "react"
import { useWindowDimensions } from "../../../hooks/use-window-dimensions"
import Button from "../../fundamentals/button"
import CrossIcon from "../../fundamentals/icons/cross-icon"
type ModalState = {
@@ -42,7 +43,7 @@ type ModalType = React.FC<ModalProps> & {
const Overlay: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Dialog.Overlay className="fixed bg-grey-90/40 z-50 grid top-0 left-0 right-0 bottom-0 place-items-center overflow-y-auto">
<Dialog.Overlay className="fixed top-0 bottom-0 left-0 right-0 z-50 grid overflow-y-auto bg-grey-90/40 place-items-center">
{children}
</Dialog.Overlay>
)
@@ -56,7 +57,7 @@ const Content: React.FC<React.PropsWithChildren> = ({ children }) => {
return (
<Dialog.Content
style={style}
className="bg-grey-0 min-w-modal rounded-rounded overflow-x-hidden"
className="overflow-x-hidden min-w-modal rounded-rounded bg-grey-0"
>
{children}
</Dialog.Content>
@@ -108,7 +109,7 @@ Modal.Content = ({ children, className }) => {
<div
style={style}
className={clsx(
"px-7 pt-5 overflow-y-auto",
"overflow-y-auto px-8 pt-6",
{
["w-largeModal pb-7"]: isLargeModal,
["pb-5"]: !isLargeModal,
@@ -124,17 +125,22 @@ Modal.Content = ({ children, className }) => {
Modal.Header = ({ handleClose = undefined, children }) => {
return (
<div
className="pl-7 pt-3.5 pr-3.5 flex flex-col w-full"
className="flex items-center w-full px-8 py-6 border-b"
onClick={(e) => e.stopPropagation()}
>
<div className="pb-1 flex w-full justify-end">
<div className="flex flex-grow">{children}</div>
<div className="self-end">
{handleClose && (
<button onClick={handleClose} className="text-grey-50 cursor-pointer">
<Button
variant="ghost"
size="small"
onClick={handleClose}
className="text-grey-50 cursor-pointer border p-1.5"
>
<CrossIcon size={20} />
</button>
</Button>
)}
</div>
{children}
</div>
)
}
@@ -146,9 +152,9 @@ Modal.Footer = ({ children, className }) => {
<div
onClick={(e) => e.stopPropagation()}
className={clsx(
"px-7 bottom-0 pb-5 flex w-full",
"bottom-0 flex w-full px-7 pb-5",
{
"border-t border-grey-20 pt-4": isLargeModal,
"border-grey-20 border-t pt-4": isLargeModal,
},
className
)}
@@ -1,7 +1,7 @@
import clsx from "clsx"
import React, { ReactNode, useContext, useReducer } from "react"
import Button from "../../fundamentals/button"
import ArrowLeftIcon from "../../fundamentals/icons/arrow-left-icon"
import UTurnIcon from "../../fundamentals/icons/u-turn-icon"
import Modal, { ModalProps } from "../../molecules/modal"
enum LayeredModalActions {
@@ -112,13 +112,13 @@ const LayeredModal: React.FC<LayeredModalProps> = ({
<Button
variant="ghost"
size="small"
className="h-8 w-8 text-grey-50"
className="w-8 h-8 border text-grey-50"
onClick={screen.onBack}
>
<ArrowLeftIcon size={20} />
<UTurnIcon size={20} />
</Button>
<div className="flex items-center gap-x-2xsmall">
<h2 className="inter-xlarge-semibold ml-5">{screen.title}</h2>
<h2 className="ml-4 inter-xlarge-semibold">{screen.title}</h2>
{screen.subtitle && (
<span className="inter-xlarge-regular text-grey-50">
({screen.subtitle})
@@ -1,15 +1,15 @@
import React, { ReactNode, useState } from "react"
import clsx from "clsx"
import { ReactNode, useState } from "react"
import Modal from "../../molecules/modal"
import Button from "../../fundamentals/button"
import FileIcon from "../../fundamentals/icons/file-icon"
import TrashIcon from "../../fundamentals/icons/trash-icon"
import DownloadIcon from "../../fundamentals/icons/download-icon"
import XCircleIcon from "../../fundamentals/icons/x-circle-icon"
import CheckCircleIcon from "../../fundamentals/icons/check-circle-icon"
import WarningCircle from "../../fundamentals/icons/warning-circle"
import CrossIcon from "../../fundamentals/icons/cross-icon"
import DownloadIcon from "../../fundamentals/icons/download-icon"
import FileIcon from "../../fundamentals/icons/file-icon"
import Modal from "../../molecules/modal"
import TrashIcon from "../../fundamentals/icons/trash-icon"
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
import XCircleIcon from "../../fundamentals/icons/x-circle-icon"
import clsx from "clsx"
type FileSummaryProps = {
name: string
@@ -34,16 +34,16 @@ function FileSummary(props: FileSummaryProps) {
<div className="relative">
<div
style={{ width: `${progress}%` }}
className="absolute bg-grey-5 h-full transition-width duration-150 ease-in-out"
className="bg-grey-5 transition-width absolute h-full duration-150 ease-in-out"
/>
<div className="relative flex items-center rounded-xl border border-1 mt-6">
<div className="border-1 relative mt-6 flex items-center rounded-xl border">
<div className="m-4">
<FileIcon size={30} fill={progress ? "#9CA3AF" : "#2DD4BF"} />
</div>
<div className="flex-1 my-6">
<div className="text-small leading-5 text-grey-90">{name}</div>
<div className="text-xsmall leading-4 text-grey-50">
<div className="my-6 flex-1">
<div className="text-small text-grey-90 leading-5">{name}</div>
<div className="text-xsmall text-grey-50 leading-4">
{status || formattedSize}
</div>
</div>
@@ -68,18 +68,18 @@ function UploadSummary(props: UploadSummaryProps) {
const { creations, updates, rejections, type } = props
return (
<div className="flex gap-6">
<div className="flex items-center text-small text-grey-90">
<div className="text-small text-grey-90 flex items-center">
<CheckCircleIcon color="#9CA3AF" className="mr-2" />
<span className="font-semibold"> {creations}&nbsp;</span> new {type}
</div>
{updates && (
<div className="flex items-center text-small text-grey-90">
<WarningCircle fill="#9CA3AF" className="mr-2" />
<div className="text-small text-grey-90 flex items-center">
<WarningCircleIcon fill="#9CA3AF" className="mr-2" />
<span className="font-semibold">{updates}&nbsp;</span> updates
</div>
)}
{rejections && (
<div className="flex items-center text-small text-grey-90">
<div className="text-small text-grey-90 flex items-center">
<XCircleIcon color="#9CA3AF" className="mr-2" />
<span className="font-semibold">{rejections}&nbsp;</span> rejections
</div>
@@ -124,7 +124,7 @@ function DropArea(props: DropAreaProps) {
onDragOver={onDragOver}
onDrop={handleFileDrop}
className={clsx(
"flex flex-col justify-center items-center border border-dashed rounded-xl mt-3 p-6",
"mt-3 flex flex-col items-center justify-center rounded-xl border border-dashed p-6",
{ "opacity-50": isDragOver }
)}
>
@@ -208,7 +208,7 @@ function UploadModal(props: UploadModalProps) {
<Modal.Body>
<Modal.Content>
<div className="flex justify-between">
<span className="text-2xl text-grey-90 inter-large-semibold py-4">
<span className="text-grey-90 inter-large-semibold py-4 text-2xl">
Import {fileTitle}
</span>
<button onClick={onClose} className="text-grey-50 cursor-pointer">
@@ -216,7 +216,7 @@ function UploadModal(props: UploadModalProps) {
</button>
</div>
<div className="text-grey-90 text-base inter-large-semibold mb-1">
<div className="text-grey-90 inter-large-semibold mb-1 text-base">
Import {fileTitle}
</div>
@@ -241,14 +241,14 @@ function UploadModal(props: UploadModalProps) {
// TODO: change this to actual progress once this we can track upload
progress={100}
action={
<a className="w-6 h-6 cursor-pointer" onClick={removeFile}>
<a className="h-6 w-6 cursor-pointer" onClick={removeFile}>
<TrashIcon stroke="#9CA3AF" />
</a>
}
/>
)}
<div className="text-grey-90 text-base inter-large-semibold mt-8">
<div className="text-grey-90 inter-large-semibold mt-8 text-base">
{description2Title}
</div>
@@ -259,7 +259,7 @@ function UploadModal(props: UploadModalProps) {
size={2967}
action={
<a
className="w-6 h-6 cursor-pointer"
className="h-6 w-6 cursor-pointer"
href={templateLink}
download
>
@@ -271,11 +271,11 @@ function UploadModal(props: UploadModalProps) {
<div className="h-2" />
</Modal.Content>
<Modal.Footer>
<div className="flex w-full h-8 justify-end">
<div className="flex h-8 w-full justify-end">
<div className="flex gap-2">
<Button
variant="secondary"
className="mr-2 text-small justify-center"
className="text-small mr-2 justify-center"
size="small"
onClick={onClose}
>
@@ -1,4 +1,4 @@
import React, { useMemo } from "react"
import { useMemo } from "react"
import { Controller } from "react-hook-form"
import { Column, useTable } from "react-table"
import { FormImage } from "../../../types/shared"
@@ -38,7 +38,7 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
return (
<div className="py-base ml-large">
<img
className="h-[80px] w-[80px] object-cover rounded"
className="h-[80px] w-[80px] rounded object-cover"
src={value}
/>
</div>
@@ -71,7 +71,7 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
},
{
Header: () => (
<div className="flex gap-x-[6px] items-center justify-center">
<div className="flex items-center justify-center gap-x-[6px]">
<span>Thumbnail</span>
<IconTooltip content="Select which image you want to use as the thumbnail for this product" />
</div>
@@ -97,7 +97,7 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
onClick={() => onDelete(row.index)}
variant="ghost"
size="small"
className="p-1 text-grey-40 cursor-pointer mx-6"
className="p-1 mx-6 cursor-pointer text-grey-40"
type="button"
>
<TrashIcon size={20} />
@@ -108,19 +108,14 @@ const ImageTable = ({ data, form, onDelete }: ImageTableProps) => {
]
}, [])
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
} = useTable({
columns,
data,
defaultColumn: {
width: "auto",
},
})
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
useTable({
columns,
data,
defaultColumn: {
width: "auto",
},
})
return (
<Table {...getTableProps()}>
@@ -0,0 +1,471 @@
import { Cell, Row, TableRowProps, usePagination, useTable } from "react-table"
import {
InventoryItemDTO,
InventoryLevelDTO,
ProductVariant,
} from "@medusajs/medusa"
import React, { useEffect, useMemo, useState } from "react"
import {
useAdminInventoryItems,
useAdminStockLocations,
useAdminStore,
useAdminUpdateLocationLevel,
useAdminVariant,
} from "medusa-react"
import Button from "../../fundamentals/button"
import ImagePlaceholder from "../../fundamentals/image-placeholder"
import InputField from "../../molecules/input"
import InputHeader from "../../fundamentals/input-header"
import InventoryFilter from "../../../domain/inventory/filter-dropdown"
import Modal from "../../molecules/modal"
import { NextSelect } from "../../molecules/select/next-select"
import Spinner from "../../atoms/spinner"
import Table from "../../molecules/table"
import TableContainer from "../../../components/organisms/table-container"
import { getErrorMessage } from "../../../utils/error-messages"
import { isEmpty } from "lodash"
import qs from "qs"
import { useInventoryFilters } from "./use-inventory-filters"
import useInventoryTableColumn from "./use-inventory-column"
import { useLocation } from "react-router-dom"
import useNotification from "../../../hooks/use-notification"
import useToggleState from "../../../hooks/use-toggle-state"
const DEFAULT_PAGE_SIZE = 15
type InventoryTableProps = {}
const defaultQueryProps = {}
const LocationDropdown = ({
selectedLocation,
onChange,
}: {
selectedLocation: string
onChange: (id: string) => void
}) => {
const { stock_locations: locations, isLoading } = useAdminStockLocations()
useEffect(() => {
if (!selectedLocation && !isLoading && locations) {
onChange(locations[0].id)
}
}, [isLoading, locations, onChange, selectedLocation])
const selectedLocObj = useMemo(() => {
if (!isLoading && locations) {
return locations.find((l) => l.id === selectedLocation)
}
return null
}, [selectedLocation, locations, isLoading])
if (isLoading || !locations || !selectedLocObj) {
return null
}
return (
<div className="h-[40px] w-[200px]">
<NextSelect
isMulti={false}
onChange={(loc) => {
onChange(loc!.value)
}}
options={locations.map((l) => ({
label: l.name,
value: l.id,
}))}
value={{ value: selectedLocObj.id, label: selectedLocObj.name }}
/>
</div>
)
}
const InventoryTable: React.FC<InventoryTableProps> = () => {
const { store } = useAdminStore()
const location = useLocation()
const defaultQuery = useMemo(() => {
if (store) {
return {
...defaultQueryProps,
location_id: store.default_location_id,
}
}
return defaultQueryProps
}, [store])
const {
removeTab,
setTab,
saveTab,
availableTabs: filterTabs,
activeFilterTab,
reset,
paginate,
setFilters,
setLocationFilter,
filters,
setQuery: setFreeText,
queryObject,
representationObject,
} = useInventoryFilters(location.search, defaultQuery)
const offs = parseInt(queryObject.offset) || 0
const limit = parseInt(queryObject.limit)
const [query, setQuery] = useState(queryObject.query)
const [numPages, setNumPages] = useState(0)
const clearFilters = () => {
reset()
setQuery("")
}
const { inventory_items, isLoading, count } = useAdminInventoryItems(
{
...queryObject,
},
{
enabled: !!store,
}
)
useEffect(() => {
const controlledPageCount = Math.ceil(count! / queryObject.limit)
setNumPages(controlledPageCount)
}, [inventory_items])
const updateUrlFromFilter = (obj = {}) => {
const stringified = qs.stringify(obj)
window.history.replaceState(`/a/inventory`, "", `${`?${stringified}`}`)
}
const refreshWithFilters = () => {
const filterObj = representationObject
if (isEmpty(filterObj)) {
updateUrlFromFilter({ offset: 0, limit: DEFAULT_PAGE_SIZE })
} else {
updateUrlFromFilter(filterObj)
}
}
useEffect(() => {
refreshWithFilters()
}, [representationObject])
const [columns] = useInventoryTableColumn()
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
gotoPage,
canPreviousPage,
canNextPage,
pageCount,
nextPage,
previousPage,
// Get the state from the instance
state: { pageIndex },
} = useTable(
{
columns,
data: inventory_items || [],
manualPagination: true,
initialState: {
pageIndex: Math.floor(offs / limit),
pageSize: limit,
},
pageCount: numPages,
autoResetPage: false,
},
usePagination
)
// Debounced search
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
if (query) {
setFreeText(query)
gotoPage(0)
} else {
if (typeof query !== "undefined") {
// if we delete query string, we reset the table view
reset()
}
}
}, 400)
return () => clearTimeout(delayDebounceFn)
}, [query])
const handleNext = () => {
if (canNextPage) {
paginate(1)
nextPage()
}
}
const handlePrev = () => {
if (canPreviousPage) {
paginate(-1)
previousPage()
}
}
return (
<TableContainer
hasPagination
pagingState={{
count: count || 0,
offset: offs,
pageSize: offs + rows.length,
title: "Inventory Items",
currentPage: pageIndex + 1,
pageCount: pageCount,
nextPage: handleNext,
prevPage: handlePrev,
hasNext: canNextPage,
hasPrev: canPreviousPage,
}}
numberOfRows={limit}
isLoading={isLoading}
>
<Table
filteringOptions={
<InventoryFilter
filters={filters}
submitFilters={setFilters}
clearFilters={clearFilters}
tabs={filterTabs}
onTabClick={setTab}
activeTab={activeFilterTab}
onRemoveTab={removeTab}
onSaveTab={saveTab}
/>
}
enableSearch
handleSearch={setQuery}
searchValue={query}
tableActions={
<LocationDropdown
selectedLocation={
queryObject.location_id || store?.default_location_id
}
onChange={(id) => {
setLocationFilter(id)
gotoPage(0)
}}
/>
}
{...getTableProps()}
>
<Table.Head>
{headerGroups?.map((headerGroup) => {
const { key, ...rest } = headerGroup.getHeaderGroupProps()
return (
<Table.HeadRow key={key} {...rest}>
{headerGroup.headers.map((col) => {
const { key, ...rest } = col.getHeaderProps()
return (
<Table.HeadCell
className="min-w-[100px]"
key={key}
{...rest}
>
{col.render("Header")}
</Table.HeadCell>
)
})}
</Table.HeadRow>
)
})}
</Table.Head>
<Table.Body {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row)
const { key, ...rest } = row.getRowProps()
return <InventoryRow row={row} key={key} {...rest} />
})}
</Table.Body>
</Table>
</TableContainer>
)
}
const InventoryRow = ({
row,
...rest
}: {
row: Row<
Partial<InventoryItemDTO> & {
location_levels?: InventoryLevelDTO[] | undefined
variants?: ProductVariant[] | undefined
}
>
} & TableRowProps) => {
const inventory = row.original
const {
state: isShowingAdjustAvailabilityModal,
open: showAdjustAvailabilityModal,
close: closeAdjustAvailabilityModal,
} = useToggleState()
return (
<Table.Row
color={"inherit"}
onClick={showAdjustAvailabilityModal}
forceDropdown
{...rest}
>
{row.cells.map((cell: Cell, index: number) => {
const { key, ...rest } = cell.getCellProps()
return (
<Table.Cell {...rest} key={key}>
{cell.render("Cell", { index })}
</Table.Cell>
)
})}
{isShowingAdjustAvailabilityModal && (
<AdjustAvailabilityModal
inventory={inventory}
handleClose={closeAdjustAvailabilityModal}
/>
)}
</Table.Row>
)
}
const AdjustAvailabilityModal = ({
inventory,
handleClose,
}: {
inventory: Partial<InventoryItemDTO> & {
location_levels?: InventoryLevelDTO[] | undefined
variants?: ProductVariant[] | undefined
}
handleClose: () => void
}) => {
const inventoryVariantId = inventory.variants?.[0]?.id
const locationLevel = inventory.location_levels?.[0]
const { variant, isLoading } = useAdminVariant(inventoryVariantId || "")
const {
mutate: updateLocationLevelForInventoryItem,
isLoading: isSubmitting,
} = useAdminUpdateLocationLevel(inventory.id!)
const notification = useNotification()
const [stockedQuantity, setStockedQuantity] = useState(
locationLevel?.stocked_quantity || 0
)
const disableSubmit =
stockedQuantity === (locationLevel?.stocked_quantity || 0) ||
!variant ||
!locationLevel
const onSubmit = () => {
updateLocationLevelForInventoryItem(
{
stockLocationId: locationLevel!.location_id,
stocked_quantity: stockedQuantity,
},
{
onSuccess: () => {
notification(
"Success",
"Inventory item updated successfully",
"success"
)
handleClose()
},
onError: (error) => {
notification("Error", getErrorMessage(error), "error")
},
}
)
}
return (
<Modal handleClose={handleClose}>
<Modal.Body>
<Modal.Header handleClose={handleClose}>
<h1 className="inter-large-semibold">Adjust availability</h1>
</Modal.Header>
<Modal.Content>
{isLoading ? (
<Spinner />
) : (
<div className="grid grid-cols-2">
<InputHeader label="Item" />
<InputHeader label="Quantity" />
<div className="flex flex-col">
<span className="pr-base">
<div className="float-left my-1.5 mr-4 flex h-[40px] w-[30px] items-center">
{variant?.product?.thumbnail ? (
<img
src={variant?.product?.thumbnail}
className="object-cover h-full rounded-rounded"
/>
) : (
<ImagePlaceholder />
)}
</div>
<div className="flex flex-col">
<span className="truncate">
{variant?.product?.title}
<span className="truncate text-grey-50">
({inventory.sku})
</span>
</span>
<span className="text-grey-50">
{variant?.options?.map((o) => (
<span key={o.id}>{o.value}</span>
))}
</span>
</div>
</span>
</div>
<InputField
onChange={(e) => setStockedQuantity(e.target.valueAsNumber)}
autoFocus
type="number"
value={stockedQuantity}
/>
</div>
)}
</Modal.Content>
</Modal.Body>
<Modal.Footer>
<div className="flex justify-end w-full gap-x-xsmall">
<Button
size="small"
variant="ghost"
className="border"
onClick={handleClose}
>
Cancel
</Button>
<Button
size="small"
variant="primary"
disabled={disableSubmit}
loading={isSubmitting}
onClick={onSubmit}
>
Save and close
</Button>
</div>
</Modal.Footer>
</Modal>
)
}
export default InventoryTable
@@ -0,0 +1,71 @@
import { useMemo } from "react"
import ImagePlaceholder from "../../fundamentals/image-placeholder"
const useInventoryTableColumn = () => {
const columns = useMemo(
() => [
{
Header: "Item",
accessor: "title",
Cell: ({ row: { original } }) => {
return (
<div className="flex items-center">
<div className="my-1.5 mr-4 flex h-[40px] w-[30px] items-center">
{original.variants[0]?.product?.thumbnail ? (
<img
src={original.variants[0].product.thumbnail}
className="object-cover h-full rounded-soft"
/>
) : (
<ImagePlaceholder />
)}
</div>
{original.variants[0]?.product?.title || ""}
</div>
)
},
},
{
Header: "Variant",
Cell: ({ row: { original } }) => {
return <div>{original?.variants[0]?.title || "-"}</div>
},
},
{
Header: "SKU",
accessor: "sku",
Cell: ({ cell: { value } }) => value,
},
{
Header: "Incoming",
accessor: "incoming_quantity",
Cell: ({ row: { original } }) => (
<div>
{original.location_levels.reduce(
(acc, next) => acc + next.incoming_quantity,
0
)}
</div>
),
},
{
Header: "In stock",
accessor: "stocked_quantity",
Cell: ({ row: { original } }) => (
<div>
{original.location_levels.reduce(
(acc, next) => acc + next.stocked_quantity,
0
)}
</div>
),
},
],
[]
)
return [columns] as const
}
export default useInventoryTableColumn
@@ -0,0 +1,466 @@
import { omit } from "lodash"
import qs from "qs"
import { useMemo, useReducer, useState } from "react"
import { relativeDateFormatToTimestamp } from "../../../utils/time"
type InventoryDateFilter = null | {
gt?: string
lt?: string
}
type InventoryFilterAction =
| { type: "setQuery"; payload: string | null }
| { type: "setFilters"; payload: InventoryFilterState }
| { type: "reset"; payload: InventoryFilterState }
| { type: "setOffset"; payload: number }
| { type: "setDefaults"; payload: InventoryDefaultFilters | null }
| { type: "setLocation"; payload: string }
| { type: "setLimit"; payload: number }
interface InventoryFilterState {
query?: string | null
limit: number
offset: number
location: string
additionalFilters: InventoryDefaultFilters | null
}
const allowedFilters = ["location", "q", "offset", "limit"]
const DefaultTabs = {}
const formatDateFilter = (filter: InventoryDateFilter) => {
if (filter === null) {
return filter
}
const dateFormatted = Object.entries(filter).reduce((acc, [key, value]) => {
if (value.includes("|")) {
acc[key] = relativeDateFormatToTimestamp(value)
} else {
acc[key] = value
}
return acc
}, {})
return dateFormatted
}
const reducer = (
state: InventoryFilterState,
action: InventoryFilterAction
): InventoryFilterState => {
switch (action.type) {
case "setFilters": {
return {
...state,
query: action?.payload?.query,
}
}
case "setQuery": {
return {
...state,
query: action.payload,
}
}
case "setLimit": {
return {
...state,
limit: action.payload,
}
}
case "setOffset": {
return {
...state,
offset: action.payload,
}
}
case "setLocation": {
return {
...state,
location: action.payload,
}
}
case "reset": {
return action.payload
}
default: {
return state
}
}
}
type InventoryDefaultFilters = {
expand?: string
fields?: string
location_id?: string
}
const eqSet = (as: Set<string>, bs: Set<string>) => {
if (as.size !== bs.size) {
return false
}
for (const a of as) {
if (!bs.has(a)) {
return false
}
}
return true
}
export const useInventoryFilters = (
existing?: string,
defaultFilters: InventoryDefaultFilters | null = null
) => {
if (existing && existing[0] === "?") {
existing = existing.substring(1)
}
const initial = useMemo(
() => parseQueryString(existing, defaultFilters),
[existing, defaultFilters]
)
const initialTabs = useMemo(() => {
const storageString = localStorage.getItem("inventory::filters")
if (storageString) {
const savedTabs = JSON.parse(storageString)
if (savedTabs) {
return Object.entries(savedTabs).map(([key, value]) => {
return {
label: key,
value: key,
removable: true,
representationString: value,
}
})
}
}
return []
}, [])
const [state, dispatch] = useReducer(reducer, initial)
const [tabs, setTabs] = useState(initialTabs)
const setDefaultFilters = (filters: InventoryDefaultFilters | null) => {
dispatch({ type: "setDefaults", payload: filters })
}
const setLimit = (limit: number) => {
dispatch({ type: "setLimit", payload: limit })
}
const setLocationFilter = (loc: string) => {
dispatch({ type: "setLocation", payload: loc })
}
const paginate = (direction: 1 | -1) => {
if (direction > 0) {
const nextOffset = state.offset + state.limit
dispatch({ type: "setOffset", payload: nextOffset })
} else {
const nextOffset = Math.max(state.offset - state.limit, 0)
dispatch({ type: "setOffset", payload: nextOffset })
}
}
const reset = () => {
dispatch({
type: "setFilters",
payload: {
...state,
offset: 0,
query: null,
},
})
}
const setFilters = (filters: InventoryFilterState) => {
dispatch({ type: "setFilters", payload: filters })
}
const setQuery = (queryString: string | null) => {
dispatch({ type: "setQuery", payload: queryString })
}
const getQueryObject = () => {
const toQuery: any = { ...state.additionalFilters }
for (const [key, value] of Object.entries(state)) {
if (key === "query") {
if (value && typeof value === "string") {
toQuery["q"] = value
}
} else if (key === "offset" || key === "limit") {
toQuery[key] = value
} else if (value.open) {
if (key === "date") {
toQuery[stateFilterMap[key]] = formatDateFilter(
value.filter as InventoryDateFilter
)
} else {
toQuery[stateFilterMap[key]] = value.filter
}
} else if (key === "location") {
toQuery[stateFilterMap[key]] = value
}
}
return toQuery
}
const getQueryString = () => {
const obj = getQueryObject()
return qs.stringify(obj, { skipNulls: true })
}
const getRepresentationObject = (fromObject?: InventoryFilterState) => {
const objToUse = fromObject ?? state
const toQuery: any = {}
for (const [key, value] of Object.entries(objToUse)) {
if (key === "query") {
if (value && typeof value === "string") {
toQuery["q"] = value
}
} else if (key === "offset" || key === "limit") {
toQuery[key] = value
} else if (value.open) {
toQuery[stateFilterMap[key]] = value.filter
}
}
return toQuery
}
const getRepresentationString = () => {
const obj = getRepresentationObject()
return qs.stringify(obj, { skipNulls: true })
}
const queryObject = useMemo(() => getQueryObject(), [state])
const representationObject = useMemo(() => getRepresentationObject(), [state])
const representationString = useMemo(() => getRepresentationString(), [state])
const activeFilterTab = useMemo(() => {
const clean = omit(representationObject, ["limit", "offset"])
const stringified = qs.stringify(clean)
const existsInSaved = tabs.find(
(el) => el.representationString === stringified
)
if (existsInSaved) {
return existsInSaved.value
}
for (const [tab, conditions] of Object.entries(DefaultTabs)) {
let match = true
if (Object.keys(clean).length !== Object.keys(conditions).length) {
continue
}
for (const [filter, value] of Object.entries(conditions)) {
if (filter in clean) {
if (Array.isArray(value)) {
match =
Array.isArray(clean[filter]) &&
eqSet(new Set(clean[filter]), new Set(value))
} else {
match = clean[filter] === value
}
} else {
match = false
}
if (!match) {
break
}
}
if (match) {
return tab
}
}
return null
}, [representationObject, tabs])
const availableTabs = useMemo(() => {
return [...tabs]
}, [tabs])
const setTab = (tabName: string) => {
let tabToUse: object | null = null
if (tabName in DefaultTabs) {
tabToUse = DefaultTabs[tabName]
} else {
const tabFound = tabs.find((t) => t.value === tabName)
if (tabFound) {
tabToUse = qs.parse(tabFound.representationString)
}
}
if (tabToUse) {
const toSubmit = {
...state,
}
for (const [filter, val] of Object.entries(tabToUse)) {
toSubmit[filterStateMap[filter]] = {
open: true,
filter: val,
}
}
dispatch({ type: "setFilters", payload: toSubmit })
}
}
const saveTab = (tabName: string, filters: InventoryFilterState) => {
const repObj = getRepresentationObject({ ...filters })
const clean = omit(repObj, ["limit", "offset"])
const repString = qs.stringify(clean, { skipNulls: true })
const storedString = localStorage.getItem("inventory::filters")
let existing: null | object = null
if (storedString) {
existing = JSON.parse(storedString)
}
if (existing) {
existing[tabName] = repString
localStorage.setItem("inventory::filters", JSON.stringify(existing))
} else {
const newFilters = {}
newFilters[tabName] = repString
localStorage.setItem("inventory::filters", JSON.stringify(newFilters))
}
setTabs((prev) => {
const duplicate = prev.findIndex(
(prev) => prev.label?.toLowerCase() === tabName.toLowerCase()
)
if (duplicate !== -1) {
prev.splice(duplicate, 1)
}
return [
...prev,
{
label: tabName,
value: tabName,
representationString: repString,
removable: true,
},
]
})
dispatch({ type: "setFilters", payload: filters })
}
const removeTab = (tabValue: string) => {
const storedString = localStorage.getItem("products::filters")
let existing: null | object = null
if (storedString) {
existing = JSON.parse(storedString)
}
if (existing) {
delete existing[tabValue]
localStorage.setItem("products::filters", JSON.stringify(existing))
}
setTabs((prev) => {
const newTabs = prev.filter((p) => p.value !== tabValue)
return newTabs
})
}
return {
...state,
filters: {
...state,
},
removeTab,
saveTab,
setTab,
availableTabs,
activeFilterTab,
representationObject,
representationString,
queryObject,
paginate,
getQueryObject,
getQueryString,
setQuery,
setFilters,
setDefaultFilters,
setLocationFilter,
setLimit,
reset,
}
}
const filterStateMap = {
location_id: "location",
}
const stateFilterMap = {
location: "location_id",
}
const parseQueryString = (
queryString?: string,
additionals: InventoryDefaultFilters | null = null
): InventoryFilterState => {
const defaultVal: InventoryFilterState = {
location: additionals?.location_id ?? "",
offset: 0,
limit: 15,
additionalFilters: additionals,
}
if (queryString) {
const filters = qs.parse(queryString)
for (const [key, value] of Object.entries(filters)) {
if (allowedFilters.includes(key)) {
switch (key) {
case "offset": {
if (typeof value === "string") {
defaultVal.offset = parseInt(value)
}
break
}
case "limit": {
if (typeof value === "string") {
defaultVal.limit = parseInt(value)
}
break
}
case "location_id": {
if (typeof value === "string") {
defaultVal.location = value
}
break
}
case "q": {
if (typeof value === "string") {
defaultVal.query = value
}
break
}
default: {
break
}
}
}
}
}
return defaultVal
}
@@ -46,7 +46,7 @@ const useProductTableColumn = ({ setTileView, setListView, showList }) => {
{original.thumbnail ? (
<img
src={original.thumbnail}
className="rounded-soft h-full object-cover"
className="object-cover h-full rounded-soft"
/>
) : (
<ImagePlaceholder />
@@ -0,0 +1,102 @@
import clsx from "clsx"
import { useMemo, useEffect, useState } from "react"
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
import FilterDropdownContainer from "../../components/molecules/filter-dropdown/container"
import TabFilter from "../../components/molecules/filter-tab"
const ProductsFilter = ({
filters,
submitFilters,
clearFilters,
tabs,
onTabClick,
activeTab,
onRemoveTab,
onSaveTab,
}) => {
const [tempState, setTempState] = useState(filters)
const [name, setName] = useState("")
const handleRemoveTab = (val) => {
if (onRemoveTab) {
onRemoveTab(val)
}
}
const handleTabClick = (tabName: string) => {
if (onTabClick) {
onTabClick(tabName)
}
}
useEffect(() => {
setTempState(filters)
}, [filters])
const onSubmit = () => {
submitFilters(tempState)
}
const onClear = () => {
clearFilters()
}
const numberOfFilters = useMemo(
() =>
Object.entries(filters || {}).reduce((acc, [, value]) => {
if (value?.open) {
acc = acc + 1
}
return acc
}, 0),
[filters]
)
const setSingleFilter = (filterKey, filterVal) => {
setTempState((prevState) => ({
...prevState,
[filterKey]: filterVal,
}))
}
return (
<div className="flex space-x-1">
<FilterDropdownContainer
submitFilters={onSubmit}
clearFilters={onClear}
triggerElement={
<button
className={clsx(
"rounded-rounded focus-visible:shadow-input focus-visible:border-violet-60 flex items-center space-x-1 focus-visible:outline-none"
)}
>
<div className="flex items-center h-6 px-2 border rounded-rounded bg-grey-5 border-grey-20 inter-small-semibold">
Filters
<div className="flex items-center ml-1 rounded text-grey-40">
<span className="text-violet-60 inter-small-semibold">
{numberOfFilters ? numberOfFilters : "0"}
</span>
</div>
</div>
<div className="flex items-center p-1 border rounded-rounded bg-grey-5 border-grey-20 inter-small-semibold">
<PlusIcon size={14} />
</div>
</button>
}
></FilterDropdownContainer>
{tabs &&
tabs.map((t) => (
<TabFilter
key={t.value}
onClick={() => handleTabClick(t.value)}
label={t.label}
isActive={activeTab === t.value}
removable={!!t.removable}
onRemove={() => handleRemoveTab(t.value)}
/>
))}
</div>
)
}
export default ProductsFilter
@@ -1,4 +1,5 @@
import BodyCard from "../../../components/organisms/body-card"
import InventoryTable from "../../../components/templates/inventory-table"
import InventoryPageTableHeader from "../header"
const InventoryView = () => {
@@ -9,7 +10,7 @@ const InventoryView = () => {
customHeader={<InventoryPageTableHeader activeView="inventory" />}
className="h-fit"
>
<h1>Inventory</h1>
<InventoryTable />
</BodyCard>
</div>
</div>
@@ -1,6 +1,7 @@
import {
AdminPostStockLocationsReq,
StockLocationAddressDTO,
StockLocationAddressInput,
StockLocationDTO,
} from "@medusajs/medusa"
import { useAdminUpdateStockLocation } from "medusa-react"
@@ -98,16 +99,20 @@ const LocationEditModal = ({ onClose, location }: LocationEditModalProps) => {
const createPayload = (data): AdminPostStockLocationsReq => {
const { general, address } = data
return {
name: general.name,
address: {
let addressInput
if (address.address_1) {
addressInput = {
company: address.company,
address_1: address.address_1,
address_2: address.address_2,
postal_code: address.postal_code,
city: address.city,
country_code: address.country_code.value || address.country_code,
},
country_code: address.country_code?.value || address.country_code,
} as StockLocationAddressInput
}
return {
name: general.name,
address: addressInput,
}
}
@@ -0,0 +1,306 @@
import React, { useEffect, useMemo } from "react"
import { LineItem, Order, ReservationItemDTO } from "@medusajs/medusa"
import FocusModal from "../../../../components/molecules/modal/focus-modal"
import Button from "../../../../components/fundamentals/button"
import CrossIcon from "../../../../components/fundamentals/icons/cross-icon"
import Select from "../../../../components/molecules/select/next-select/select"
import {
useAdminCreateReservation,
useAdminStockLocations,
useAdminVariantsInventory,
useMedusa,
} from "medusa-react"
import { Controller, useForm, useWatch } from "react-hook-form"
import Thumbnail from "../../../../components/atoms/thumbnail"
import InputField from "../../../../components/molecules/input"
import { NestedForm, nestedForm } from "../../../../utils/nested-form"
import { sum } from "lodash"
import clsx from "clsx"
import { getFulfillableQuantity } from "../create-fulfillment/item-table"
import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages"
type AllocationModalFormData = {
location?: { label: string; value: string }
items: AllocationLineItemForm[]
}
type AllocateItemsModalProps = {
order: Order
reservationItemsMap: Record<string, ReservationItemDTO[]>
close: () => void
}
const AllocateItemsModal: React.FC<AllocateItemsModalProps> = ({
order,
close,
reservationItemsMap,
}) => {
const { mutateAsync: createReservation } = useAdminCreateReservation()
const { client: medusaClient } = useMedusa()
const notification = useNotification()
const form = useForm<AllocationModalFormData>({
defaultValues: {
items: [],
},
})
const { handleSubmit, control } = form
const selectedLocation = useWatch({ control, name: "location" })
// if not sales channel is present fetch all locations
const stockLocationsFilter: { sales_channel_id?: string } = {}
if (order.sales_channel_id) {
stockLocationsFilter.sales_channel_id = order.sales_channel_id
}
const { stock_locations, isLoading } =
useAdminStockLocations(stockLocationsFilter)
const locationOptions = useMemo(() => {
if (!stock_locations) {
return []
}
return stock_locations.map((sl) => ({
value: sl.id,
label: sl.name,
}))
}, [stock_locations])
const onSubmit = async (data: AllocationModalFormData) => {
if (!data.location?.value) {
return
}
const results: { result?: ReservationItemDTO; error?: Error }[] =
await Promise.all(
data.items.map(async (item) => {
if (!item.quantity) {
return {}
}
return await createReservation({
quantity: item.quantity,
line_item_id: item.line_item_id,
inventory_item_id: item.inventory_item_id,
location_id: data.location!.value,
})
.then((result) => ({ result }))
.catch((error: Error) => ({ error }))
})
)
if (results.some((r) => r.error)) {
await Promise.all(
results.map(async ({ result }) => {
if (result) {
await medusaClient.admin.reservations.delete(result.id)
}
})
)
const error = results
.filter(({ error }) => !!error)
.map(({ error }) => getErrorMessage(error))
.join(", ")
notification("Couldn't allocate items", error, "error")
} else {
notification(
"Items allocated",
"Items have been allocated successfully",
"success"
)
close()
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FocusModal>
<FocusModal.Header>
<div className="flex w-full justify-between px-8 medium:w-8/12">
<Button size="small" variant="ghost" type="button" onClick={close}>
<CrossIcon size={20} />
</Button>
<div className="flex gap-x-small">
<Button
size="small"
variant="secondary"
type="button"
onClick={close}
>
Cancel
</Button>
<Button size="small" variant="primary" type="submit">
Save allocation
</Button>
</div>
</div>
</FocusModal.Header>
<FocusModal.Main className="medium:w-6/12">
{isLoading || !stock_locations ? (
<div>Loading...</div>
) : (
<div className="mt-16 flex flex-col">
<h1 className="inter-xlarge-semibold">Allocate order items</h1>
<div className="mt-6 flex w-full items-center justify-between">
<div>
<p className="inter-base-semibold">Location</p>
<p className="inter-base-regular">
Choose where you wish to allocate from
</p>
</div>
<div className="w-1/2">
<Controller
name="location"
control={control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Select
value={value}
onChange={onChange}
options={locationOptions}
/>
)}
/>
</div>
</div>
<div
className={clsx(
"mt-8 flex w-full flex-col border-t border-grey-20",
{
"pointer-events-none opacity-50": !selectedLocation?.value,
}
)}
>
<div>
<p className="inter-base-semibold mt-8">Items to allocate</p>
<p className="inter-base-regular">
Select the number of items that you wish to allocate.
</p>
{order.items?.map((item, i) => {
return (
<AllocationLineItem
form={nestedForm(form, `items.${i}` as "items.0")}
item={item}
key={i}
locationId={selectedLocation?.value}
reservedQuantity={sum(
reservationItemsMap[item.id]?.map(
(reservation) => reservation.quantity
) || []
)}
/>
)
})}
</div>
</div>
</div>
)}
</FocusModal.Main>
</FocusModal>
</form>
)
}
export type AllocationLineItemForm = {
inventory_item_id: string
line_item_id: string
quantity: number
}
export const AllocationLineItem: React.FC<{
form: NestedForm<AllocationLineItemForm>
item: LineItem
locationId?: string
reservedQuantity?: number
}> = ({ form, item, locationId, reservedQuantity }) => {
const { variant, isLoading } = useAdminVariantsInventory(
item.variant_id as string
)
const { register, path } = form
form.setValue(path("line_item_id"), item.id)
useEffect(() => {
if (variant?.inventory) {
form.setValue(path("inventory_item_id"), variant.inventory[0].id)
}
}, [variant, form, path])
const { availableQuantity, inStockQuantity } = useMemo(() => {
if (isLoading || !locationId || !variant) {
return {}
}
const { inventory } = variant
const locationInventory = inventory[0].location_levels?.find(
(inv) => inv.location_id === locationId
)
if (!locationInventory) {
return {}
}
return {
availableQuantity: locationInventory.available_quantity,
inStockQuantity: locationInventory.stocked_quantity,
}
}, [variant, locationId, isLoading])
const lineItemReservationCapacity =
getFulfillableQuantity(item) - (reservedQuantity || 0)
const inventoryItemReservationCapacity =
typeof availableQuantity === "number" ? availableQuantity : 0
const maxReservation = Math.min(
lineItemReservationCapacity,
inventoryItemReservationCapacity
)
return (
<div>
<div className="mt-8 flex w-full items-center justify-between">
<div className="flex gap-x-base">
<Thumbnail size="medium" src={item.thumbnail} />
<div className="text-grey-50">
<p className="flex gap-x-2xsmall">
<p className="inter-base-semibold text-grey-90">{item.title}</p>
{`(${item.variant.sku})`}
</p>
<p className="inter-base-regular ">
{item.variant.options?.map((option) => option.value) ||
item.variant.title ||
"-"}
</p>
</div>
</div>
<div className="flex items-center gap-x-large">
<div className="inter-base-regular flex flex-col items-end whitespace-nowrap text-grey-50">
<p>{availableQuantity || "N/A"} available</p>
<p>({inStockQuantity || "N/A"} in stock)</p>
</div>
<InputField
{...register(path(`quantity`), { valueAsNumber: true })}
type="number"
defaultValue={0}
disabled={lineItemReservationCapacity < 0}
min={0}
max={maxReservation > 0 ? maxReservation : 0}
suffix={
<span className="flex">
{"/"}{" "}
<span className="ml-1">
{maxReservation > 0 ? maxReservation : 0}
</span>
</span>
}
/>
</div>
</div>
</div>
)
}
export default AllocateItemsModal
@@ -0,0 +1,201 @@
import { useEffect, useMemo } from "react"
import CrossIcon from "../../../../components/fundamentals/icons/cross-icon"
import Button from "../../../../components/fundamentals/button"
import {
AllocationLineItem,
AllocationLineItemForm,
} from "./allocate-items-modal"
import { Controller, useForm, useWatch } from "react-hook-form"
import {
useAdminDeleteReservation,
useAdminStockLocations,
useAdminUpdateReservation,
} from "medusa-react"
import Select from "../../../../components/molecules/select/next-select/select"
import { LineItem, ReservationItemDTO } from "@medusajs/medusa"
import useNotification from "../../../../hooks/use-notification"
import { nestedForm } from "../../../../utils/nested-form"
import SideModal from "../../../../components/molecules/modal/side-modal"
type EditAllocationLineItemForm = {
location: { label: string; value: string }
item: AllocationLineItemForm
}
const EditAllocationDrawer = ({
close,
reservation,
item,
sales_channel_id,
totalReservedQuantity,
}: {
close: () => void
reservation?: ReservationItemDTO
item: LineItem
totalReservedQuantity: number
sales_channel_id?: string
}) => {
const form = useForm<EditAllocationLineItemForm>()
const { control, setValue, handleSubmit } = form
// if not sales channel is present fetch all locations
const stockLocationsFilter: { sales_channel_id?: string } = {}
if (sales_channel_id) {
stockLocationsFilter.sales_channel_id = sales_channel_id
}
const { stock_locations } = useAdminStockLocations(stockLocationsFilter)
const { mutate: updateReservation } = useAdminUpdateReservation(
reservation?.id || ""
)
const { mutate: deleteReservation } = useAdminDeleteReservation(
reservation?.id || ""
)
const locationOptions = useMemo(() => {
if (!stock_locations) {
return []
}
return stock_locations.map((sl) => ({
value: sl.id,
label: sl.name,
}))
}, [stock_locations])
const notification = useNotification()
const handleDelete = () => {
deleteReservation(undefined, {
onSuccess: () => {
notification("Success", "Allocation deleted successfully", "success")
close()
},
onError: () => {
notification("Errors", "Failed to deleted ", "success")
},
})
}
const selectedLocation = useWatch({
control,
name: "location",
})
useEffect(() => {
if (stock_locations?.length && reservation) {
const defaultLocation = stock_locations.find(
(sl) => sl.id === reservation.location_id
)
if (defaultLocation) {
setValue("location", {
value: defaultLocation?.id,
label: defaultLocation?.name,
})
}
}
}, [stock_locations, reservation, setValue])
useEffect(() => {
if (reservation) {
setValue("item.quantity", reservation?.quantity)
}
}, [reservation, setValue])
const submit = (data: EditAllocationLineItemForm) => {
updateReservation(
{
quantity: data.item.quantity,
location_id: data.location.value,
inventory_item_id: data.item.inventory_item_id,
},
{
onSuccess: () => {
notification("Success", "Allocation updated successfully", "success")
close()
},
onError: () => {
notification("Errors", "Failed to update allocation", "error")
},
}
)
}
return (
<SideModal isVisible close={close}>
<form
className="w-full h-full text-grey-90"
onSubmit={handleSubmit(submit)}
>
<div className="flex flex-col justify-between h-full ">
<div>
<div className="flex items-center justify-between px-8 py-6 border-b border-grey-20">
<h1 className="inter-large-semibold ">Edit allocation</h1>
<Button variant="ghost" className="p-1.5" onClick={close}>
<CrossIcon />
</Button>
</div>
<div className="flex flex-col px-8 pt-6 gap-y-8">
<div>
<h2 className="inter-base-semibold">Location</h2>
<span className="inter-base-regular text-grey-50">
Choose which location you want to ship the items from.
</span>
<Controller
name="location"
control={control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Select
value={value}
onChange={onChange}
options={locationOptions}
/>
)}
/>
</div>
<div>
<h2 className="inter-base-semibold">Items to Allocate</h2>
<span className="inter-base-regular text-grey-50">
Select the number of items that you wish to allocate.
</span>
<AllocationLineItem
form={nestedForm(form, `item` as "item")}
item={item}
locationId={selectedLocation?.value}
reservedQuantity={
totalReservedQuantity - (reservation?.quantity || 0)
}
/>
</div>
<Button
variant="ghost"
className="w-full my-1 border text-rose-50"
size="small"
onClick={handleDelete}
>
Delete allocation
</Button>
</div>
</div>
<div className="flex justify-end w-full px-8 pt-4 pb-6 border-t gap-x-xsmall">
<Button
variant="ghost"
size="small"
className="border"
onClick={close}
>
Cancel
</Button>
<Button variant="primary" size="small" type="submit">
Save and close
</Button>
</div>
</div>
</form>
</SideModal>
)
}
export default EditAllocationDrawer
@@ -6,22 +6,29 @@ import {
Order,
Swap,
} from "@medusajs/medusa"
import CreateFulfillmentItemsTable, {
getFulfillableQuantity,
} from "./item-table"
import Metadata, {
MetadataField,
} from "../../../../components/organisms/metadata"
import React, { useState } from "react"
import {
useAdminCreateFulfillment,
useAdminFulfillClaim,
useAdminFulfillSwap,
useAdminStockLocations,
} from "medusa-react"
import React, { useState } from "react"
import Button from "../../../../components/fundamentals/button"
import CheckIcon from "../../../../components/fundamentals/icons/check-icon"
import IconTooltip from "../../../../components/molecules/icon-tooltip"
import Modal from "../../../../components/molecules/modal"
import Metadata, {
MetadataField,
} from "../../../../components/organisms/metadata"
import useNotification from "../../../../hooks/use-notification"
import CrossIcon from "../../../../components/fundamentals/icons/cross-icon"
import FeatureToggle from "../../../../components/fundamentals/feature-toggle"
import FocusModal from "../../../../components/molecules/modal/focus-modal"
import Select from "../../../../components/molecules/select/next-select/select"
import Switch from "../../../../components/atoms/switch"
import { getErrorMessage } from "../../../../utils/error-messages"
import CreateFulfillmentItemsTable from "./item-table"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import useNotification from "../../../../hooks/use-notification"
type CreateFulfillmentModalProps = {
handleCancel: () => void
@@ -36,13 +43,62 @@ const CreateFulfillmentModal: React.FC<CreateFulfillmentModalProps> = ({
orderToFulfill,
orderId,
}) => {
const [toFulfill, setToFulfill] = useState<string[]>([])
const [quantities, setQuantities] = useState({})
const { isFeatureEnabled } = useFeatureFlag()
const isLocationFulfillmentEnabled =
isFeatureEnabled("inventoryService") &&
isFeatureEnabled("stockLocationService")
const [quantities, setQuantities] = useState<Record<string, number>>(
"object" in orderToFulfill
? (orderToFulfill as Order).items.reduce((acc, next) => {
return {
...acc,
[next.id]: getFulfillableQuantity(next),
}
}, {})
: {}
)
const [noNotis, setNoNotis] = useState(false)
const [errors, setErrors] = useState({})
const [locationSelectValue, setLocationSelectValue] = useState<{
value?: string
label?: string
}>({})
const [metadata, setMetadata] = useState<MetadataField[]>([
{ key: "", value: "" },
])
const salesChannelId =
"object" in orderToFulfill
? (orderToFulfill as Order).sales_channel_id
: (orderToFulfill as ClaimOrder | Swap)?.order?.sales_channel_id
const filterableFields: { sales_channel_id?: string } = {}
if (salesChannelId) {
filterableFields.sales_channel_id = salesChannelId
}
const { stock_locations, refetch } = useAdminStockLocations(
filterableFields,
{
enabled: isLocationFulfillmentEnabled,
}
)
React.useEffect(() => {
if (isLocationFulfillmentEnabled) {
refetch()
}
}, [isLocationFulfillmentEnabled, refetch])
const locationOptions = React.useMemo(() => {
if (!stock_locations) {
return []
}
return stock_locations.map((sl) => ({
value: sl.id,
label: sl.name,
}))
}, [stock_locations])
const items =
"items" in orderToFulfill
? orderToFulfill.items
@@ -60,6 +116,20 @@ const CreateFulfillmentModal: React.FC<CreateFulfillmentModalProps> = ({
const notification = useNotification()
const createFulfillment = () => {
if (isLocationFulfillmentEnabled && !locationSelectValue.value) {
notification("Error", "Please select a location to fulfill from", "error")
return
}
if (Object.keys(errors).length > 0) {
notification(
"Can't allow this action",
"Trying to fulfill more than in stock",
"error"
)
return
}
const [type] = orderToFulfill.id.split("_")
type actionType =
@@ -108,9 +178,13 @@ const CreateFulfillmentModal: React.FC<CreateFulfillmentModalProps> = ({
metadata: preparedMetadata,
no_notification: noNotis,
} as AdminPostOrdersOrderFulfillmentsReq
requestObj.items = toFulfill
.map((itemId) => ({ item_id: itemId, quantity: quantities[itemId] }))
.filter((t) => !!t)
requestObj.items = Object.entries(quantities)
.filter(([, value]) => !!value)
.map(([key, value]) => ({
item_id: key,
quantity: value,
}))
break
}
@@ -124,77 +198,95 @@ const CreateFulfillmentModal: React.FC<CreateFulfillmentModalProps> = ({
}
return (
<Modal handleClose={handleCancel}>
<Modal.Body>
<Modal.Header handleClose={handleCancel}>
<span className="inter-xlarge-semibold">Create Fulfillment</span>
</Modal.Header>
<Modal.Content>
<div className="flex flex-col">
<span className="inter-base-semibold mb-2">Items</span>
<CreateFulfillmentItemsTable
items={items}
toFulfill={toFulfill}
setToFulfill={setToFulfill}
quantities={quantities}
setQuantities={setQuantities}
/>
<FocusModal>
<FocusModal.Header>
<div className="medium:w-8/12 flex w-full justify-between px-8">
<Button
size="small"
variant="ghost"
type="button"
onClick={handleCancel}
>
<CrossIcon size={20} />
</Button>
<div className="gap-x-small flex">
<Button
size="small"
variant="secondary"
type="button"
onClick={handleCancel}
>
Cancel
</Button>
<Button
size="small"
variant="primary"
type="submit"
loading={isSubmitting}
onClick={createFulfillment}
>
Create fulfillment
</Button>
</div>
</div>
</FocusModal.Header>
<FocusModal.Main className="medium:w-6/12">
<div className="pt-16">
<h1 className="inter-xlarge-semibold">Create Fulfillment</h1>
<div className="grid-col-1 grid gap-y-8 divide-y [&>*]:pt-8">
<FeatureToggle featureFlag="inventoryService">
<div className="grid grid-cols-2">
<div>
<h2 className="inter-base-semibold">Locations</h2>
<span className="text-grey-50">
Choose where you wish to fulfill from.
</span>
</div>
<Select
isMulti={false}
options={locationOptions}
value={locationSelectValue}
onChange={(option) => {
setLocationSelectValue({
value: option?.value,
label: option?.label,
})
}}
/>
</div>
</FeatureToggle>
<div className="flex flex-col">
<span className="inter-base-semibold ">Items to fulfill</span>
<span className="text-grey-50 mb-6">
Select the number of items that you wish to fulfill.
</span>
<CreateFulfillmentItemsTable
items={items}
quantities={quantities}
setQuantities={setQuantities}
locationId={locationSelectValue.value}
setErrors={setErrors}
/>
</div>
<div className="mt-4">
<Metadata metadata={metadata} setMetadata={setMetadata} />
</div>
</div>
</Modal.Content>
<Modal.Footer>
<div className="flex w-full h-8 justify-between">
<div
className="items-center h-full flex cursor-pointer"
onClick={() => setNoNotis(!noNotis)}
>
<div
className={`w-5 h-5 flex justify-center text-grey-0 border-grey-30 border rounded-base ${
!noNotis && "bg-violet-60"
}`}
>
<span className="self-center">
{!noNotis && <CheckIcon size={16} />}
</span>
<div>
<div className="mb-2xsmall flex items-center justify-between">
<h2 className="inter-base-semibold">Send notifications</h2>
<Switch
checked={!noNotis}
onCheckedChange={(checked) => setNoNotis(!checked)}
/>
</div>
<input
id="noNotification"
className="hidden"
name="noNotification"
checked={!noNotis}
type="checkbox"
/>
<span className="ml-3 flex items-center text-grey-90 gap-x-xsmall">
Send notifications
<IconTooltip content="" />
</span>
</div>
<div className="flex">
<Button
variant="ghost"
className="mr-2 w-32 text-small justify-center"
size="large"
onClick={handleCancel}
>
Cancel
</Button>
<Button
size="large"
className="w-32 text-small justify-center"
variant="primary"
disabled={!toFulfill?.length || isSubmitting}
onClick={createFulfillment}
loading={isSubmitting}
>
Complete
</Button>
<p className="inter-base-regular text-grey-50">
When toggled, notification emails will be sent.
</p>
</div>
</div>
</Modal.Footer>
</Modal.Body>
</Modal>
</div>
</FocusModal.Main>
</FocusModal>
)
}
@@ -1,161 +1,179 @@
import { LineItem } from "@medusajs/medusa"
import clsx from "clsx"
import React from "react"
import CheckIcon from "../../../../components/fundamentals/icons/check-icon"
import MinusIcon from "../../../../components/fundamentals/icons/minus-icon"
import PlusIcon from "../../../../components/fundamentals/icons/plus-icon"
import Table from "../../../../components/molecules/table"
import React, { useMemo } from "react"
const getFulfillableQuantity = (item: LineItem): number => {
return item.quantity - item.fulfilled_quantity - item.returned_quantity
import FeatureToggle from "../../../../components/fundamentals/feature-toggle"
import ImagePlaceholder from "../../../../components/fundamentals/image-placeholder"
import InputField from "../../../../components/molecules/input"
import { LineItem } from "@medusajs/medusa"
import { useAdminVariantsInventory } from "medusa-react"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
export const getFulfillableQuantity = (item: LineItem): number => {
return item.quantity - (item.fulfilled_quantity || 0)
}
const CreateFulfillmentItemsTable = ({
items,
toFulfill,
setToFulfill,
quantities,
setQuantities,
locationId,
setErrors,
}: {
items: LineItem[]
quantities: Record<string, number>
setQuantities: (quantities: Record<string, number>) => void
locationId: string
setErrors: (errors: React.SetStateAction<{}>) => void
}) => {
const handleQuantity = (upOrDown, item) => {
const current = quantities[item.id]
const handleQuantityUpdate = (value: number, id: string) => {
let newQuantities = { ...quantities }
if (upOrDown === -1) {
newQuantities = {
...newQuantities,
[item.id]: current - 1,
}
} else {
newQuantities = {
...newQuantities,
[item.id]: current + 1,
}
newQuantities = {
...newQuantities,
[id]: value,
}
setQuantities(newQuantities)
}
const handleFulfillmentItemToggle = (item) => {
const id = item.id
const idxOfToggled = toFulfill.indexOf(id)
// if already in fulfillment items, you unchecked the item
// so we remove the item
if (idxOfToggled !== -1) {
const newFulfills = [...toFulfill]
newFulfills.splice(idxOfToggled, 1)
setToFulfill(newFulfills)
} else {
const newFulfills = [...toFulfill, id]
setToFulfill(newFulfills)
const newQuantities = {
...quantities,
[item.id]: getFulfillableQuantity(item),
}
setQuantities(newQuantities)
}
}
return (
<Table>
<Table.HeadRow className="text-grey-50 inter-small-semibold border-t border-t-grey-20">
<Table.HeadCell>Details</Table.HeadCell>
<Table.HeadCell />
<Table.HeadCell className="text-right pr-8">Quantity</Table.HeadCell>
</Table.HeadRow>
<Table.Body>
{items
?.filter((i) => getFulfillableQuantity(i) > 0)
.map((item) => {
const checked = toFulfill.includes(item.id)
return (
<>
<Table.Row className={"border-b-grey-0 hover:bg-grey-0"}>
<Table.Cell className="w-[50px]">
<div className="items-center ml-1 h-full flex">
<div
onClick={() => handleFulfillmentItemToggle(item)}
className={`w-5 h-5 flex justify-center text-grey-0 border-grey-30 border cursor-pointer rounded-base ${
checked && "bg-violet-60"
}`}
>
<span className="self-center">
{checked && <CheckIcon size={16} />}
</span>
</div>
<input
className="hidden"
checked={checked}
tabIndex={-1}
onChange={() => handleFulfillmentItemToggle(item)}
type="checkbox"
/>
</div>
</Table.Cell>
<Table.Cell>
<div className="min-w-[240px] flex py-2">
<div className="w-[30px] h-[40px] ">
<img
className="h-full w-full object-cover rounded"
src={item.thumbnail}
/>
</div>
<div className="inter-small-regular text-grey-50 flex flex-col ml-4">
<span>
<span className="text-grey-90">{item.title}</span>
</span>
<span>{item?.variant?.title || ""}</span>
</div>
</div>
</Table.Cell>
<Table.Cell className="text-right w-32 pr-8">
{toFulfill.includes(item.id) ? (
<div className="flex w-full text-right justify-end text-grey-50 ">
<span
onClick={() => handleQuantity(-1, item)}
className={clsx(
"w-5 h-5 flex text-grey-50 items-center justify-center rounded cursor-pointer hover:bg-grey-20 mr-2",
{
["pointer-events-none text-grey-30"]:
quantities[item.id] === 1,
}
)}
>
<MinusIcon size={16} />
</span>
<span>{quantities[item.id] || ""}</span>
<span
onClick={() => handleQuantity(1, item)}
className={clsx(
"w-5 h-5 flex text-grey-50 items-center justify-center rounded cursor-pointer hover:bg-grey-20 ml-2",
{
["pointer-events-none text-grey-30"]:
item.quantity - item.fulfilled_quantity ===
quantities[item.id],
}
)}
>
<PlusIcon size={16} />
</span>
</div>
) : (
<span className="text-grey-40">
{getFulfillableQuantity(item)}
</span>
)}
</Table.Cell>
</Table.Row>
</>
)
})}
</Table.Body>
</Table>
<div>
{items.map((item, idx) => {
return (
<FulfillmentLine
item={item}
locationId={locationId}
key={`fulfillmentLine-${idx}`}
quantities={quantities}
handleQuantityUpdate={handleQuantityUpdate}
setErrors={setErrors}
/>
)
})}
</div>
)
}
const FulfillmentLine = ({
item,
locationId,
quantities,
handleQuantityUpdate,
setErrors,
}: {
locationId: string
item: LineItem
quantities: Record<string, number>
handleQuantityUpdate: (value: number, id: string) => void
setErrors: (errors: Record<string, string>) => void
}) => {
const { isFeatureEnabled } = useFeatureFlag()
const isLocationFulfillmentEnabled =
isFeatureEnabled("inventoryService") &&
isFeatureEnabled("stockLocationService")
const { variant, isLoading, refetch } = useAdminVariantsInventory(
item.variant_id as string,
{ enabled: isLocationFulfillmentEnabled }
)
React.useEffect(() => {
if (isLocationFulfillmentEnabled) {
refetch()
}
}, [isLocationFulfillmentEnabled, refetch])
const { availableQuantity, inStockQuantity } = useMemo(() => {
if (isLoading || !locationId || !variant) {
return {}
}
const { inventory } = variant
const locationInventory = inventory[0].location_levels?.find(
(inv) => inv.location_id === locationId
)
if (!locationInventory) {
return {}
}
return {
availableQuantity: locationInventory.available_quantity,
inStockQuantity: locationInventory.stocked_quantity,
}
}, [variant, locationId, isLoading])
const validQuantity =
!locationId ||
(locationId &&
(!availableQuantity || quantities[item.id] < availableQuantity))
React.useEffect(() => {
setErrors((errors) => {
if (validQuantity) {
delete errors[item.id]
return { errors }
}
errors[item.id] = "Quantity is not valid"
return { errors }
})
}, [validQuantity, setErrors, item.id])
if (getFulfillableQuantity(item) <= 0) {
return null
}
return (
<div className="rounded-rounded hover:bg-grey-5 mx-[-5px] mb-1 flex h-[64px] justify-between py-2 px-[5px]">
<div className="flex justify-center space-x-4">
<div className="rounded-rounded flex h-[48px] w-[36px] overflow-hidden">
{item.thumbnail ? (
<img src={item.thumbnail} className="object-cover" />
) : (
<ImagePlaceholder />
)}
</div>
<div className="flex max-w-[185px] flex-col justify-center">
<span className="inter-small-regular text-grey-90 truncate">
{item.title}
</span>
{item?.variant && (
<span className="inter-small-regular text-grey-50 truncate">
{`${item.variant.title}${
item.variant.sku ? ` (${item.variant.sku})` : ""
}`}
</span>
)}
</div>
</div>
<div className="flex items-center">
<FeatureToggle featureFlag="inventoryService">
<div className="inter-base-regular text-grey-50 mr-6 flex flex-col items-end whitespace-nowrap">
<p>{availableQuantity || "N/A"} available</p>
<p>({inStockQuantity || "N/A"} in stock)</p>
</div>
</FeatureToggle>
<InputField
type="number"
name={`quantity`}
defaultValue={getFulfillableQuantity(item)}
min={0}
suffix={
<span className="flex">
{"/"}
<span className="pl-1">{getFulfillableQuantity(item)}</span>
</span>
}
value={quantities[item.id]}
max={getFulfillableQuantity(item)}
onChange={(e) =>
handleQuantityUpdate(e.target.valueAsNumber, item.id)
}
errors={
validQuantity ? undefined : { quantity: "Quantity is not valid" }
}
/>
</div>
</div>
)
}
export default CreateFulfillmentItemsTable
@@ -0,0 +1,233 @@
import { DisplayTotal, PaymentDetails } from "../templates"
import { Order, ReservationItemDTO } from "@medusajs/medusa"
import React, { useContext, useMemo } from "react"
import { ActionType } from "../../../../components/molecules/actionables"
import AllocateItemsModal from "../allocations/allocate-items-modal"
import Badge from "../../../../components/fundamentals/badge"
import BodyCard from "../../../../components/organisms/body-card"
import CopyToClipboard from "../../../../components/atoms/copy-to-clipboard"
import { OrderEditContext } from "../../edit/context"
import OrderLine from "../order-line"
import StatusIndicator from "../../../../components/fundamentals/status-indicator"
import { sum } from "lodash"
import { useAdminReservations } from "medusa-react"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import useToggleState from "../../../../hooks/use-toggle-state"
type SummaryCardProps = {
order: Order
}
const SummaryCard: React.FC<SummaryCardProps> = ({
order,
}: {
order: Order
}) => {
const {
state: allocationModalIsOpen,
open: showAllocationModal,
close: closeAllocationModal,
} = useToggleState()
const { showModal } = useContext(OrderEditContext)
const { isFeatureEnabled } = useFeatureFlag()
const inventoryEnabled = isFeatureEnabled("inventoryService")
const { reservations, isLoading, refetch } = useAdminReservations(
{
line_item_id: order.items.map((item) => item.id),
},
{
enabled: inventoryEnabled,
}
)
React.useEffect(() => {
if (inventoryEnabled) {
refetch()
}
}, [inventoryEnabled, refetch])
const reservationItemsMap = useMemo(() => {
if (!reservations?.length || !inventoryEnabled || isLoading) {
return {}
}
return reservations.reduce(
(acc: Record<string, ReservationItemDTO[]>, item: ReservationItemDTO) => {
if (!item.line_item_id) {
return acc
}
acc[item.line_item_id] = acc[item.line_item_id]
? [...acc[item.line_item_id], item]
: [item]
return acc
},
{}
)
}, [reservations, inventoryEnabled, isLoading])
const allItemsReserved = useMemo(() => {
return order.items.every((item) => {
const reservations = reservationItemsMap[item.id]
if (!reservations) {
return false
}
return sum(reservations.map((r) => r.quantity)) === item.quantity
})
}, [reservationItemsMap, order])
const { hasMovements, swapAmount, manualRefund, swapRefund, returnRefund } =
useMemo(() => {
let manualRefund = 0
let swapRefund = 0
let returnRefund = 0
const swapAmount = sum(order?.swaps.map((s) => s.difference_due) || [0])
if (order?.refunds?.length) {
order.refunds.forEach((ref) => {
if (ref.reason === "other" || ref.reason === "discount") {
manualRefund += ref.amount
}
if (ref.reason === "return") {
returnRefund += ref.amount
}
if (ref.reason === "swap") {
swapRefund += ref.amount
}
})
}
return {
hasMovements:
swapAmount + manualRefund + swapRefund + returnRefund !== 0,
swapAmount,
manualRefund,
swapRefund,
returnRefund,
}
}, [order])
const actionables = useMemo(() => {
const actionables: ActionType[] = []
if (isFeatureEnabled("order_editing")) {
actionables.push({
label: "Edit Order",
onClick: showModal,
})
}
if (isFeatureEnabled("inventoryService")) {
actionables.push({
label: "Allocate",
onClick: showAllocationModal,
})
}
return actionables
}, [showModal, isFeatureEnabled, showAllocationModal])
return (
<BodyCard
className={"mb-4 h-auto min-h-0 w-full"}
title="Summary"
status={
isFeatureEnabled("inventoryService") &&
Array.isArray(reservations) && (
<StatusIndicator
variant={allItemsReserved ? "success" : "danger"}
title={allItemsReserved ? "Allocated" : "Awaits allocation"}
className="rounded-rounded border px-3 py-1.5"
/>
)
}
actionables={actionables}
>
<div className="mt-6">
{order.items?.map((item, i) => (
<OrderLine
key={i}
item={item}
currencyCode={order.currency_code}
reservations={reservationItemsMap[item.id]}
/>
))}
<DisplayTotal
currency={order.currency_code}
totalAmount={order.subtotal}
totalTitle={"Subtotal"}
/>
{order?.discounts?.map((discount, index) => (
<DisplayTotal
key={index}
currency={order.currency_code}
totalAmount={-1 * order.discount_total}
totalTitle={
<div className="inter-small-regular text-grey-90 flex items-center">
Discount:{" "}
<Badge className="ml-3" variant="default">
{discount.code}
</Badge>
</div>
}
/>
))}
{order?.gift_cards?.map((giftCard, index) => (
<DisplayTotal
key={index}
currency={order.currency_code}
totalAmount={-1 * order.gift_card_total}
totalTitle={
<div className="inter-small-regular text-grey-90 flex items-center">
Gift card:
<Badge className="ml-3" variant="default">
{giftCard.code}
</Badge>
<div className="ml-2">
<CopyToClipboard
value={giftCard.code}
showValue={false}
iconSize={16}
/>
</div>
</div>
}
/>
))}
<DisplayTotal
currency={order.currency_code}
totalAmount={order.shipping_total}
totalTitle={"Shipping"}
/>
<DisplayTotal
currency={order.currency_code}
totalAmount={order.tax_total}
totalTitle={`Tax`}
/>
<DisplayTotal
variant={"large"}
currency={order.currency_code}
totalAmount={order.total}
totalTitle={hasMovements ? "Original Total" : "Total"}
/>
<PaymentDetails
manualRefund={manualRefund}
swapAmount={swapAmount}
swapRefund={swapRefund}
returnRefund={returnRefund}
paidTotal={order.paid_total}
refundedTotal={order.refunded_total}
currency={order.currency_code}
/>
</div>
{allocationModalIsOpen && (
<AllocateItemsModal
reservationItemsMap={reservationItemsMap}
order={order}
close={closeAllocationModal}
/>
)}
</BodyCard>
)
}
export default SummaryCard
@@ -1,55 +1,4 @@
import { Address, ClaimOrder, Fulfillment, Swap } from "@medusajs/medusa"
import { capitalize, sum } from "lodash"
import {
useAdminCancelOrder,
useAdminCapturePayment,
useAdminOrder,
useAdminRegion,
useAdminUpdateOrder,
} from "medusa-react"
import moment from "moment"
import { useMemo, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { useNavigate, useParams } from "react-router-dom"
import Avatar from "../../../components/atoms/avatar"
import CopyToClipboard from "../../../components/atoms/copy-to-clipboard"
import Spinner from "../../../components/atoms/spinner"
import Tooltip from "../../../components/atoms/tooltip"
import Badge from "../../../components/fundamentals/badge"
import Button from "../../../components/fundamentals/button"
import DetailsIcon from "../../../components/fundamentals/details-icon"
import CancelIcon from "../../../components/fundamentals/icons/cancel-icon"
import ClipboardCopyIcon from "../../../components/fundamentals/icons/clipboard-copy-icon"
import CornerDownRightIcon from "../../../components/fundamentals/icons/corner-down-right-icon"
import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon"
import MailIcon from "../../../components/fundamentals/icons/mail-icon"
import RefreshIcon from "../../../components/fundamentals/icons/refresh-icon"
import TruckIcon from "../../../components/fundamentals/icons/truck-icon"
import { ActionType } from "../../../components/molecules/actionables"
import Breadcrumb from "../../../components/molecules/breadcrumb"
import JSONView from "../../../components/molecules/json-view"
import BodyCard from "../../../components/organisms/body-card"
import RawJSON from "../../../components/organisms/raw-json"
import Timeline from "../../../components/organisms/timeline"
import { AddressType } from "../../../components/templates/address-form"
import TransferOrdersModal from "../../../components/templates/transfer-orders-modal"
import useClipboard from "../../../hooks/use-clipboard"
import useImperativeDialog from "../../../hooks/use-imperative-dialog"
import useNotification from "../../../hooks/use-notification"
import useToggleState from "../../../hooks/use-toggle-state"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import { isoAlpha2Countries } from "../../../utils/countries"
import { getErrorMessage } from "../../../utils/error-messages"
import extractCustomerName from "../../../utils/extract-customer-name"
import { formatAmountWithSymbol } from "../../../utils/prices"
import OrderEditProvider, { OrderEditContext } from "../edit/context"
import OrderEditModal from "../edit/modal"
import AddressModal from "./address-modal"
import CreateFulfillmentModal from "./create-fulfillment"
import EmailModal from "./email-modal"
import MarkShippedModal from "./mark-shipped"
import OrderLine from "./order-line"
import CreateRefundModal from "./refund"
import {
DisplayTotal,
FormattedAddress,
@@ -57,9 +6,57 @@ import {
FulfillmentStatusComponent,
OrderStatusComponent,
PaymentActionables,
PaymentDetails,
PaymentStatusComponent,
} from "./templates"
import OrderEditProvider, { OrderEditContext } from "../edit/context"
import {
useAdminCancelOrder,
useAdminCapturePayment,
useAdminOrder,
useAdminRegion,
useAdminUpdateOrder,
} from "medusa-react"
import { useNavigate, useParams } from "react-router-dom"
import { ActionType } from "../../../components/molecules/actionables"
import AddressModal from "./address-modal"
import { AddressType } from "../../../components/templates/address-form"
import Avatar from "../../../components/atoms/avatar"
import BodyCard from "../../../components/organisms/body-card"
import Breadcrumb from "../../../components/molecules/breadcrumb"
import Button from "../../../components/fundamentals/button"
import CancelIcon from "../../../components/fundamentals/icons/cancel-icon"
import ClipboardCopyIcon from "../../../components/fundamentals/icons/clipboard-copy-icon"
import CornerDownRightIcon from "../../../components/fundamentals/icons/corner-down-right-icon"
import CreateFulfillmentModal from "./create-fulfillment"
import CreateRefundModal from "./refund"
import DetailsIcon from "../../../components/fundamentals/details-icon"
import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon"
import EmailModal from "./email-modal"
import JSONView from "../../../components/molecules/json-view"
import MailIcon from "../../../components/fundamentals/icons/mail-icon"
import MarkShippedModal from "./mark-shipped"
import OrderEditModal from "../edit/modal"
import RawJSON from "../../../components/organisms/raw-json"
import RefreshIcon from "../../../components/fundamentals/icons/refresh-icon"
import Spinner from "../../../components/atoms/spinner"
import SummaryCard from "./detail-cards/summary"
import Timeline from "../../../components/organisms/timeline"
import Tooltip from "../../../components/atoms/tooltip"
import TransferOrdersModal from "../../../components/templates/transfer-orders-modal"
import TruckIcon from "../../../components/fundamentals/icons/truck-icon"
import { capitalize } from "lodash"
import extractCustomerName from "../../../utils/extract-customer-name"
import { formatAmountWithSymbol } from "../../../utils/prices"
import { getErrorMessage } from "../../../utils/error-messages"
import { isoAlpha2Countries } from "../../../utils/countries"
import moment from "moment"
import useClipboard from "../../../hooks/use-clipboard"
import { useHotkeys } from "react-hotkeys-hook"
import useImperativeDialog from "../../../hooks/use-imperative-dialog"
import useNotification from "../../../hooks/use-notification"
import { useState } from "react"
import useToggleState from "../../../hooks/use-toggle-state"
type OrderDetailFulfillment = {
title: string
@@ -120,7 +117,6 @@ const gatherAllFulfillments = (order) => {
const OrderDetails = () => {
const { id } = useParams()
const { isFeatureEnabled } = useFeatureFlag()
const dialog = useImperativeDialog()
const [addressModal, setAddressModal] = useState<null | {
@@ -167,37 +163,6 @@ const OrderDetails = () => {
useHotkeys("esc", () => navigate("/a/orders"))
useHotkeys("command+i", handleCopy)
const { hasMovements, swapAmount, manualRefund, swapRefund, returnRefund } =
useMemo(() => {
let manualRefund = 0
let swapRefund = 0
let returnRefund = 0
const swapAmount = sum(order?.swaps.map((s) => s.difference_due) || [0])
if (order?.refunds?.length) {
order.refunds.forEach((ref) => {
if (ref.reason === "other" || ref.reason === "discount") {
manualRefund += ref.amount
}
if (ref.reason === "return") {
returnRefund += ref.amount
}
if (ref.reason === "swap") {
swapRefund += ref.amount
}
})
}
return {
hasMovements:
swapAmount + manualRefund + swapRefund + returnRefund !== 0,
swapAmount,
manualRefund,
swapRefund,
returnRefund,
}
}, [order])
const handleDeleteOrder = async () => {
const shouldDelete = await dialog({
heading: "Cancel order",
@@ -350,101 +315,8 @@ const OrderDetails = () => {
</div>
</div>
</BodyCard>
<OrderEditContext.Consumer>
{({ showModal }) => (
<BodyCard
className={"mb-4 h-auto min-h-0 w-full"}
title="Summary"
actionables={
isFeatureEnabled("order_editing")
? [
{
label: "Edit Order",
onClick: showModal,
},
]
: undefined
}
>
<div className="mt-6">
{order.items?.map((item, i) => (
<OrderLine
key={i}
item={item}
currencyCode={order.currency_code}
/>
))}
<DisplayTotal
currency={order.currency_code}
totalAmount={order.subtotal}
totalTitle={"Subtotal"}
/>
{order?.discounts?.map((discount, index) => (
<DisplayTotal
key={index}
currency={order.currency_code}
totalAmount={-1 * order.discount_total}
totalTitle={
<div className="inter-small-regular text-grey-90 flex items-center">
Discount:{" "}
<Badge className="ml-3" variant="default">
{discount.code}
</Badge>
</div>
}
/>
))}
{order?.gift_cards?.map((giftCard, index) => (
<DisplayTotal
key={index}
currency={order.currency_code}
totalAmount={-1 * order.gift_card_total}
totalTitle={
<div className="inter-small-regular text-grey-90 flex items-center">
Gift card:
<Badge className="ml-3" variant="default">
{giftCard.code}
</Badge>
<div className="ml-2">
<CopyToClipboard
value={giftCard.code}
showValue={false}
iconSize={16}
/>
</div>
</div>
}
/>
))}
<DisplayTotal
currency={order.currency_code}
totalAmount={order.shipping_total}
totalTitle={"Shipping"}
/>
<DisplayTotal
currency={order.currency_code}
totalAmount={order.tax_total}
totalTitle={`Tax`}
/>
<DisplayTotal
variant={"large"}
currency={order.currency_code}
totalAmount={order.total}
totalTitle={hasMovements ? "Original Total" : "Total"}
/>
<PaymentDetails
manualRefund={manualRefund}
swapAmount={swapAmount}
swapRefund={swapRefund}
returnRefund={returnRefund}
paidTotal={order.paid_total}
refundedTotal={order.refunded_total}
currency={order.currency_code}
/>
</div>
</BodyCard>
)}
</OrderEditContext.Consumer>
<SummaryCard order={order} />
<BodyCard
className={"mb-4 h-auto min-h-0 w-full"}
@@ -1,25 +1,37 @@
import { LineItem } from "@medusajs/medusa"
import React from "react"
import { LineItem, ReservationItemDTO } from "@medusajs/medusa"
import Button from "../../../../components/fundamentals/button"
import CheckCircleFillIcon from "../../../../components/fundamentals/icons/check-circle-fill-icon"
import CircleQuarterSolid from "../../../../components/fundamentals/icons/circle-quarter-solid"
import EditAllocationDrawer from "../allocations/edit-allocation-modal"
import ImagePlaceholder from "../../../../components/fundamentals/image-placeholder"
import React from "react"
import Tooltip from "../../../../components/atoms/tooltip"
import WarningCircleIcon from "../../../../components/fundamentals/icons/warning-circle"
import { formatAmountWithSymbol } from "../../../../utils/prices"
import { sum } from "lodash"
import { useAdminStockLocations } from "medusa-react"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
type OrderLineProps = {
item: LineItem
currencyCode: string
reservations?: ReservationItemDTO[]
}
const OrderLine = ({ item, currencyCode }: OrderLineProps) => {
const OrderLine = ({ item, currencyCode, reservations }: OrderLineProps) => {
const { isFeatureEnabled } = useFeatureFlag()
return (
<div className="flex justify-between mb-1 h-[64px] py-2 mx-[-5px] px-[5px] hover:bg-grey-5 rounded-rounded">
<div className="flex space-x-4 justify-center">
<div className="flex h-[48px] w-[36px] rounded-rounded overflow-hidden">
<div className="hover:bg-grey-5 rounded-rounded mx-[-5px] mb-1 flex h-[64px] justify-between py-2 px-[5px]">
<div className="flex justify-center space-x-4">
<div className="rounded-rounded flex h-[48px] w-[36px] overflow-hidden">
{item.thumbnail ? (
<img src={item.thumbnail} className="object-cover" />
) : (
<ImagePlaceholder />
)}
</div>
<div className="flex flex-col justify-center max-w-[185px]">
<div className="flex max-w-[185px] flex-col justify-center">
<span className="inter-small-regular text-grey-90 truncate">
{item.title}
</span>
@@ -32,8 +44,8 @@ const OrderLine = ({ item, currencyCode }: OrderLineProps) => {
)}
</div>
</div>
<div className="flex items-center">
<div className="flex small:space-x-2 medium:space-x-4 large:space-x-6 mr-3">
<div className="flex items-center">
<div className="small:space-x-2 medium:space-x-4 large:space-x-6 mr-3 flex">
<div className="inter-small-regular text-grey-50">
{formatAmountWithSymbol({
amount: (item?.total ?? 0) / item.quantity,
@@ -45,6 +57,9 @@ const OrderLine = ({ item, currencyCode }: OrderLineProps) => {
<div className="inter-small-regular text-grey-50">
x {item.quantity}
</div>
{isFeatureEnabled("inventoryService") && (
<ReservationIndicator reservations={reservations} lineItem={item} />
)}
<div className="inter-small-regular text-grey-90">
{formatAmountWithSymbol({
amount: item.total ?? 0,
@@ -62,4 +77,98 @@ const OrderLine = ({ item, currencyCode }: OrderLineProps) => {
)
}
const ReservationIndicator = ({
reservations,
lineItem,
}: {
reservations?: ReservationItemDTO[]
lineItem: LineItem
}) => {
const { stock_locations } = useAdminStockLocations({
id: reservations?.map((r) => r.location_id) || [],
})
const [reservation, setReservation] =
React.useState<ReservationItemDTO | null>(null)
const locationMap = new Map(stock_locations?.map((l) => [l.id, l.name]) || [])
const reservationsSum = sum(reservations?.map((r) => r.quantity) || [])
const awaitingAllocation = lineItem.quantity - reservationsSum
return (
<div className={awaitingAllocation ? "text-rose-50" : "text-grey-40"}>
<Tooltip
content={
<div className="inter-small-regular flex flex-col items-center px-1 pt-1 pb-2">
<div className="gap-y-base grid grid-cols-1 divide-y">
{!!awaitingAllocation && (
<span className="flex w-full items-center">
{awaitingAllocation} items await allocation
</span>
)}
{reservations?.map((reservation) => (
<EditAllocationButton
key={reservation.id}
locationName={locationMap.get(reservation.location_id)}
totalReservedQuantity={reservationsSum}
reservation={reservation}
lineItem={lineItem}
onClick={() => setReservation(reservation)}
/>
))}
</div>
</div>
}
side="bottom"
>
{awaitingAllocation ? (
reservationsSum ? (
<CircleQuarterSolid size={20} />
) : (
<WarningCircleIcon fillType="solid" size={20} />
)
) : (
<CheckCircleFillIcon size={20} />
)}
</Tooltip>
{reservation && (
<EditAllocationDrawer
totalReservedQuantity={reservationsSum}
close={() => setReservation(null)}
reservation={reservation}
item={lineItem}
/>
)}
</div>
)
}
const EditAllocationButton = ({
reservation,
locationName,
onClick,
}: {
reservation: ReservationItemDTO
totalReservedQuantity: number
locationName?: string
lineItem: LineItem
onClick: () => void
}) => {
return (
<div className="pt-base first:pt-0">
{`${reservation.quantity} item: ${locationName}`}
<Button
onClick={onClick}
variant="ghost"
size="small"
className="mt-2 w-full border"
>
Edit Allocation
</Button>
</div>
)
}
export default OrderLine
@@ -1,19 +1,19 @@
import { useAdminCreateBatchJob } from "medusa-react"
import { useMemo, useState } from "react"
import { Route, Routes, useNavigate } from "react-router-dom"
import { useMemo, useState } from "react"
import Button from "../../components/fundamentals/button"
import ExportIcon from "../../components/fundamentals/icons/export-icon"
import BodyCard from "../../components/organisms/body-card"
import TableViewHeader from "../../components/organisms/custom-table-header"
import Button from "../../components/fundamentals/button"
import Details from "./details"
import ExportIcon from "../../components/fundamentals/icons/export-icon"
import ExportModal from "../../components/organisms/export-modal"
import OrderTable from "../../components/templates/order-table"
import useNotification from "../../hooks/use-notification"
import useToggleState from "../../hooks/use-toggle-state"
import { usePolling } from "../../providers/polling-provider"
import TableViewHeader from "../../components/organisms/custom-table-header"
import { getErrorMessage } from "../../utils/error-messages"
import Details from "./details"
import { transformFiltersAsExportContext } from "./utils"
import { useAdminCreateBatchJob } from "medusa-react"
import useNotification from "../../hooks/use-notification"
import { usePolling } from "../../providers/polling-provider"
import useToggleState from "../../hooks/use-toggle-state"
const VIEWS = ["orders", "drafts"]
@@ -1,18 +1,19 @@
import React from "react"
import { useFieldArray, UseFormReturn } from "react-hook-form"
import IconTooltip from "../../../../../components/molecules/icon-tooltip"
import InputField from "../../../../../components/molecules/input"
import Accordion from "../../../../../components/organisms/accordion"
import { nestedForm } from "../../../../../utils/nested-form"
import CustomsForm, { CustomsFormType } from "../../customs-form"
import DimensionsForm, { DimensionsFormType } from "../../dimensions-form"
import { PricesFormType } from "../../prices-form"
import { UseFormReturn, useFieldArray } from "react-hook-form"
import VariantGeneralForm, {
VariantGeneralFormType,
} from "../variant-general-form"
import VariantPricesForm from "../variant-prices-form"
import VariantStockForm, { VariantStockFormType } from "../variant-stock-form"
import Accordion from "../../../../../components/organisms/accordion"
import IconTooltip from "../../../../../components/molecules/icon-tooltip"
import InputField from "../../../../../components/molecules/input"
import { PricesFormType } from "../../prices-form"
import VariantPricesForm from "../variant-prices-form"
import { nestedForm } from "../../../../../utils/nested-form"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
export type EditFlowVariantFormType = {
/**
* Used to identify the variant during product create flow. Will not be submitted to the backend.
@@ -32,6 +33,7 @@ export type EditFlowVariantFormType = {
type Props = {
form: UseFormReturn<EditFlowVariantFormType, any>
isEdit?: boolean
}
/**
@@ -53,26 +55,29 @@ type Props = {
* )
* }
*/
const EditFlowVariantForm = ({ form }: Props) => {
const EditFlowVariantForm = ({ form, isEdit }: Props) => {
const { isFeatureEnabled } = useFeatureFlag()
const { fields } = useFieldArray({
control: form.control,
name: "options",
})
const showStockAndInventory = !isEdit || !isFeatureEnabled("inventoryService")
return (
<Accordion type="multiple" defaultValue={["general"]}>
<Accordion.Item title="General" value="general" required>
<div>
<VariantGeneralForm form={nestedForm(form, "general")} />
<div className="mt-xlarge">
<div className="flex items-center gap-x-2xsmall mb-base">
<div className="mb-base gap-x-2xsmall flex items-center">
<h3 className="inter-base-semibold">Options</h3>
<IconTooltip
type="info"
content="Options are used to define the color, size, etc. of the variant."
/>
</div>
<div className="grid grid-cols-2 gap-large pb-2xsmall">
<div className="gap-large pb-2xsmall grid grid-cols-2">
{fields.map((field, index) => {
return (
<InputField
@@ -93,9 +98,11 @@ const EditFlowVariantForm = ({ form }: Props) => {
<Accordion.Item title="Pricing" value="pricing">
<VariantPricesForm form={nestedForm(form, "prices")} />
</Accordion.Item>
<Accordion.Item title="Stock & Inventory" value="stock">
<VariantStockForm form={nestedForm(form, "stock")} />
</Accordion.Item>
{showStockAndInventory && (
<Accordion.Item title="Stock & Inventory" value="stock">
<VariantStockForm form={nestedForm(form, "stock")} />
</Accordion.Item>
)}
<Accordion.Item title="Shipping" value="shipping">
<p className="inter-base-regular text-grey-50">
Shipping information can be required depending on your shipping
@@ -1,8 +1,20 @@
import React from "react"
import { Controller } from "react-hook-form"
import Switch from "../../../../../components/atoms/switch"
import { Controller, useFieldArray } from "react-hook-form"
import { InventoryLevelDTO, StockLocationDTO } from "@medusajs/medusa"
import BuildingsIcon from "../../../../../components/fundamentals/icons/buildings-icon"
import Button from "../../../../../components/fundamentals/button"
import FeatureToggle from "../../../../../components/fundamentals/feature-toggle"
import IconBadge from "../../../../../components/fundamentals/icon-badge"
import InputField from "../../../../../components/molecules/input"
import { LayeredModalContext } from "../../../../../components/molecules/modal/layered-modal"
import { ManageLocationsScreen } from "../../variant-inventory-form/variant-stock-form"
import { NestedForm } from "../../../../../utils/nested-form"
import React from "react"
import Switch from "../../../../../components/atoms/switch"
import clsx from "clsx"
import { sum } from "lodash"
import { useAdminStockLocations } from "medusa-react"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
export type VariantStockFormType = {
manage_inventory: boolean
@@ -12,6 +24,7 @@ export type VariantStockFormType = {
ean: string | null
upc: string | null
barcode: string | null
stock_location?: { stocked_quantity: number; location_id: string }[]
}
type Props = {
@@ -19,52 +32,75 @@ type Props = {
}
const VariantStockForm = ({ form }: Props) => {
const layeredModalContext = React.useContext(LayeredModalContext)
const { isFeatureEnabled } = useFeatureFlag()
const stockLocationEnabled = isFeatureEnabled("stockLocationService")
const { stock_locations, refetch } = useAdminStockLocations(
{},
{ enabled: stockLocationEnabled }
)
React.useEffect(() => {
if (stockLocationEnabled) {
refetch()
}
}, [stockLocationEnabled, refetch])
const stockLocationsMap = React.useMemo(() => {
return new Map(stock_locations?.map((sl) => [sl.id, sl]))
}, [stock_locations])
const {
path,
control,
register,
formState: { errors },
watch,
} = form
const {
fields: selectedLocations,
append,
remove,
} = useFieldArray({
control,
name: path("stock_location"),
})
const locs = watch(
selectedLocations?.map((sl, idx) =>
path(`stock_location.${idx}.stocked_quantity`)
)
)
const totalStockedQuantity = React.useMemo(() => {
return sum(locs)
}, [locs])
const addLocations = async (data) => {
const removed = data.removed.map((r) =>
selectedLocations.findIndex((sl) => sl.location_id === r.id)
)
removed.forEach((r) => remove(r))
data.added.forEach((added) => {
append({
location_id: added,
stocked_quantity: 0,
})
})
}
return (
<div>
<p className="inter-base-regular text-grey-50">
Configure the inventory and stock for this variant.
</p>
<div className="pt-large flex flex-col gap-y-xlarge">
<div className="flex flex-col gap-y-2xsmall">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">Manage inventory</h3>
<Controller
control={control}
name={path("manage_inventory")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
<p className="inter-base-regular text-grey-50">
When checked Medusa will regulate the inventory when orders and
returns are made.
</p>
</div>
<div className="flex flex-col gap-y-2xsmall">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">Allow backorders</h3>
<Controller
control={control}
name={path("allow_backorder")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
<p className="inter-base-regular text-grey-50">
When checked the product will be available for purchase despite the
product being sold out
</p>
</div>
<div className="grid grid-cols-2 gap-large">
<div className="gap-y-xlarge pt-large flex flex-col">
<div className="gap-large grid grid-cols-2">
<InputField
label="Stock keeping unit (SKU)"
placeholder="SUN-G, JK1234..."
@@ -95,6 +131,106 @@ const VariantStockForm = ({ form }: Props) => {
{...register(path("barcode"))}
/>
</div>
<div className="gap-y-2xsmall flex flex-col">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">Manage inventory</h3>
<Controller
control={control}
name={path("manage_inventory")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
<p className="inter-base-regular text-grey-50">
When checked Medusa will regulate the inventory when orders and
returns are made.
</p>
</div>
<div className="gap-y-2xsmall flex flex-col">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">Allow backorders</h3>
<Controller
control={control}
name={path("allow_backorder")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
<p className="inter-base-regular text-grey-50">
When checked the product will be available for purchase despite the
product being sold out
</p>
</div>
<FeatureToggle featureFlag="inventoryService">
<div
className={clsx({
"pointer-events-none opacity-50 transition-opacity duration-100":
!form.watch(path("manage_inventory")),
})}
>
<div className="flex flex-col">
<div className="gap-y-2xsmall mb-4 flex flex-col">
<h3 className="inter-base-semibold mb-2xsmall">Quantity</h3>
<div className="flex items-center justify-between">
<p className="inter-base-regular text-grey-50">Location</p>
<p className="inter-base-regular text-grey-50">In Stock</p>
</div>
</div>
<div className="gap-y-base flex flex-col pb-6">
{selectedLocations.map((sl, i) => (
<div key={sl.id} className="flex items-center">
<div className="inter-base-regular flex items-center">
<IconBadge className="mr-base">
<BuildingsIcon />
</IconBadge>
{stockLocationsMap.get(sl.location_id)?.name}
</div>
<div className="ml-auto flex">
<InputField
placeholder={"0"}
type="number"
{...register(
path(`stock_location.${i}.stocked_quantity`),
{
valueAsNumber: true,
}
)}
/>
</div>
</div>
))}
</div>
{!!selectedLocations.length && (
<div className="text-grey-50 mb-6 flex items-center justify-between border-t border-dashed pt-6">
<p>Total inventory at all locations</p>
<p>{`${totalStockedQuantity} available`}</p>
</div>
)}
<Button
variant="ghost"
size="small"
className="w-full border"
type="button"
onClick={() => {
layeredModalContext.push(
// @ts-ignore
ManageLocationsScreen(
layeredModalContext.pop,
selectedLocations as InventoryLevelDTO[],
stock_locations as StockLocationDTO[],
addLocations
)
)
}}
>
Manage locations
</Button>
</div>
</div>
</FeatureToggle>
</div>
</div>
)
@@ -0,0 +1,60 @@
import { InventoryLevelDTO } from "@medusajs/medusa"
import { UseFormReturn } from "react-hook-form"
import { nestedForm } from "../../../../../utils/nested-form"
import VariantStockForm, { VariantStockFormType } from "../variant-stock-form"
export type EditFlowVariantFormType = {
stock: VariantStockFormType
}
type Props = {
form: UseFormReturn<EditFlowVariantFormType, any>
locationLevels: InventoryLevelDTO[]
refetchInventory: () => void
isLoading: boolean
itemId: string
}
/**
* Re-usable Product Variant form used to add and edit product variants.
* @example
* const MyForm = () => {
* const form = useForm<VariantFormType>()
* const { handleSubmit } = form
*
* const onSubmit = handleSubmit((data) => {
* // do something with data
* })
*
* return (
* <form onSubmit={onSubmit}>
* <VariantForm form={form} />
* <Button type="submit">Submit</Button>
* </form>
* )
* }
*/
const EditFlowVariantForm = ({
form,
isLoading,
locationLevels,
refetchInventory,
itemId,
}: Props) => {
if (isLoading) {
return null
}
return (
<>
<VariantStockForm
locationLevels={locationLevels}
refetchInventory={refetchInventory}
itemId={itemId}
form={nestedForm(form, "stock")}
/>
</>
)
}
export default EditFlowVariantForm
@@ -0,0 +1,355 @@
import React, { useMemo, useState, useContext } from "react"
import Modal from "../../../../../components/molecules/modal"
import { LayeredModalContext } from "../../../../../components/molecules/modal/layered-modal"
import {
useAdminCreateLocationLevel,
useAdminDeleteLocationLevel,
useAdminStockLocations,
} from "medusa-react"
import { InventoryLevelDTO, StockLocationDTO } from "@medusajs/medusa"
import { Controller } from "react-hook-form"
import Button from "../../../../../components/fundamentals/button"
import Switch from "../../../../../components/atoms/switch"
import InputField from "../../../../../components/molecules/input"
import { NestedForm } from "../../../../../utils/nested-form"
import IconBadge from "../../../../../components/fundamentals/icon-badge"
import BuildingsIcon from "../../../../../components/fundamentals/icons/buildings-icon"
export type VariantStockFormType = {
manage_inventory: boolean
allow_backorder: boolean
inventory_quantity: number | null
sku: string | null
ean: string | null
upc: string | null
barcode: string | null
location_levels: InventoryLevelDTO[] | null
}
type Props = {
itemId: string
locationLevels: InventoryLevelDTO[]
refetchInventory: () => void
form: NestedForm<VariantStockFormType>
}
const VariantStockForm = ({
form,
locationLevels,
refetchInventory,
itemId,
}: Props) => {
const layeredModalContext = useContext(LayeredModalContext)
const { stock_locations: locations, isLoading } = useAdminStockLocations()
const deleteLevel = useAdminDeleteLocationLevel(itemId)
const createLevel = useAdminCreateLocationLevel(itemId)
const { path, control, register } = form
const handleUpdateLocations = async (value) => {
await Promise.all(
value.removed.map(async (id) => {
await deleteLevel.mutateAsync(id)
})
)
await Promise.all(
value.added.map(async (id) => {
await createLevel.mutateAsync({
stocked_quantity: 0,
location_id: id,
})
})
)
refetchInventory()
}
return (
<div>
<div className="flex flex-col gap-y-xlarge">
<div className="flex flex-col gap-y-4">
<h3 className="inter-base-semibold">General</h3>
<div className="grid grid-cols-2 gap-large">
<InputField
label="Stock keeping unit (SKU)"
placeholder="SUN-G, JK1234..."
{...register(path("sku"))}
/>
<InputField
label="EAN (Barcode)"
placeholder="123456789102..."
{...register(path("ean"))}
/>
<InputField
label="UPC (Barcode)"
placeholder="023456789104..."
{...register(path("upc"))}
/>
<InputField
label="Barcode"
placeholder="123456789104..."
{...register(path("barcode"))}
/>
</div>
</div>
<div className="flex flex-col gap-y-2xsmall">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">Manage inventory</h3>
<Controller
control={control}
name={path("manage_inventory")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
<p className="inter-base-regular text-grey-50">
When checked Medusa will regulate the inventory when orders and
returns are made.
</p>
</div>
<div className="flex flex-col gap-y-2xsmall">
<div className="flex items-center justify-between">
<h3 className="inter-base-semibold mb-2xsmall">Allow backorders</h3>
<Controller
control={control}
name={path("allow_backorder")}
render={({ field: { value, onChange } }) => {
return <Switch checked={value} onCheckedChange={onChange} />
}}
/>
</div>
<p className="inter-base-regular text-grey-50">
When checked the product will be available for purchase despite the
product being sold out
</p>
</div>
<div className="flex flex-col w-full text-base">
<h3 className="inter-base-semibold mb-2xsmall">Quantity</h3>
{!isLoading && locations && (
<div className="flex flex-col w-full">
<div className="flex justify-between py-3 inter-base-regular text-grey-50">
<div className="">Location</div>
<div className="">In Stock</div>
</div>
{locationLevels.map((level, i) => {
const locationDetails = locations.find(
(l) => l.id === level.location_id
)
return (
<div key={level.id} className="flex items-center py-3">
<div className="flex items-center inter-base-regular">
<IconBadge className="mr-base">
<BuildingsIcon />
</IconBadge>
{locationDetails?.name}
</div>
<div className="flex ml-auto">
<div className="flex flex-col mr-base text-small text-grey-50">
<span className="whitespace-nowrap">
{`${
level.stocked_quantity - level.available_quantity
} reserved`}
</span>
<span className="whitespace-nowrap">{`${level.available_quantity} available`}</span>
</div>
<InputField
placeholder={"0"}
type="number"
{...register(
path(`location_levels.${i}.stocked_quantity`),
{ valueAsNumber: true }
)}
/>
</div>
</div>
)
})}
</div>
)}
</div>
<div className="flex">
<Button
variant="secondary"
size="small"
type="button"
className="w-full"
onClick={() => {
layeredModalContext.push(
// @ts-ignore
ManageLocationsScreen(
layeredModalContext.pop,
locationLevels,
locations,
handleUpdateLocations
)
)
}}
>
Manage locations
</Button>
</div>
</div>
</div>
)
}
export const ManageLocationsScreen = (
pop: () => void,
levels: InventoryLevelDTO[],
locations: StockLocationDTO[],
onSubmit: (value: any) => Promise<void>
) => {
return {
title: "Manage locations",
onBack: () => pop(),
view: (
<ManageLocationsForm
existingLevels={levels}
locationOptions={locations}
onSubmit={onSubmit}
/>
),
}
}
type ManageLocationFormProps = {
existingLevels: InventoryLevelDTO[]
locationOptions: StockLocationDTO[]
onSubmit: (value: any) => Promise<void>
}
const ManageLocationsForm = ({
existingLevels,
locationOptions,
onSubmit,
}: ManageLocationFormProps) => {
const layeredModalContext = useContext(LayeredModalContext)
const { pop } = layeredModalContext
const existingLocations = useMemo(() => {
return existingLevels.map((level) => level.location_id)
}, [existingLevels])
const [selectedLocations, setSelectedLocations] =
useState<string[]>(existingLocations)
const [isDirty, setIsDirty] = useState(false)
React.useEffect(() => {
const selectedIsExisting = selectedLocations.every((locationId) =>
existingLocations.includes(locationId)
)
setIsDirty(
!selectedIsExisting ||
selectedLocations.length !== existingLocations.length
)
}, [existingLocations, selectedLocations])
const handleToggleLocation = (locationId: string) => {
if (selectedLocations.includes(locationId)) {
setSelectedLocations(selectedLocations.filter((id) => id !== locationId))
} else {
setSelectedLocations([...selectedLocations, locationId])
}
}
// TODO: On submit, create location level and refetch locations if needed, so that object exists correctly
const handleSubmit = async (e) => {
e.preventDefault()
const newLevels = selectedLocations.filter(
(locationId: string) => !existingLocations.includes(locationId)
)
const removedLevels = existingLocations.filter(
(locationId) => !selectedLocations.includes(locationId)
)
await onSubmit({
added: newLevels,
removed: removedLevels,
}).then(() => {
pop()
})
}
const handleSelectAll = (e) => {
e.preventDefault()
setSelectedLocations(locationOptions.map((l) => l.id))
}
return (
<div className="w-full h-full">
<form onSubmit={handleSubmit}>
<Modal.Content>
<div>
<div className="flex items-center justify-between w-full border-b border-grey-20 pb-base text-grey-50">
<div className="">
<p>Select locations that stock the selected variant</p>
<p>{`(${selectedLocations.length} of ${locationOptions.length} selected)`}</p>
</div>
<Button
size="small"
variant="ghost"
className="border"
onClick={handleSelectAll}
>
Select all
</Button>
</div>
{locationOptions.map((loc) => {
const existingLevel = selectedLocations.find((l) => l === loc.id)
return (
<div
className="flex items-center justify-between gap-6 border-b border-grey-20 py-base"
key={loc.id}
>
<div className="flex items-center">
<IconBadge className="mr-base">
<BuildingsIcon />
</IconBadge>
<h3>{loc.name}</h3>
</div>
<Switch
checked={!!existingLevel}
onCheckedChange={() => handleToggleLocation(loc.id)}
/>
</div>
)
})}
</div>
</Modal.Content>
<Modal.Footer>
<div className="flex justify-end w-full gap-x-xsmall">
<Button
variant="ghost"
size="small"
className="w-[112px]"
onClick={() => pop()}
type="button"
>
Back
</Button>
<Button
variant="primary"
className="nowrap w-[134px]"
size="small"
type="submit"
disabled={!isDirty}
>
Save and go back
</Button>
</div>
</Modal.Footer>
</form>
</div>
)
}
export default VariantStockForm
@@ -15,6 +15,7 @@ import { useNavigate } from "react-router-dom"
import useImperativeDialog from "../../../../hooks/use-imperative-dialog"
import useNotification from "../../../../hooks/use-notification"
import { getErrorMessage } from "../../../../utils/error-messages"
import { removeNullish } from "../../../../utils/remove-nullish"
const useEditProductActions = (productId: string) => {
const dialog = useImperativeDialog()
@@ -47,14 +48,14 @@ const useEditProductActions = (productId: string) => {
const onAddVariant = (
payload: AdminPostProductsProductVariantsReq,
onSuccess: () => void,
onSuccess: (variantRes) => void,
successMessage = "Variant was created successfully"
) => {
addVariant.mutate(payload, {
onSuccess: () => {
onSuccess: (data) => {
notification("Success", successMessage, "success")
getProduct.refetch()
onSuccess()
onSuccess(data.product)
},
onError: (err) => {
notification("Error", getErrorMessage(err), "error")
@@ -70,7 +71,7 @@ const useEditProductActions = (productId: string) => {
) => {
updateVariant.mutate(
// @ts-ignore - TODO fix type on request
{ variant_id: id, ...payload },
{ variant_id: id, ...removeNullish(payload) },
{
onSuccess: () => {
notification("Success", successMessage, "success")
@@ -1,12 +1,17 @@
import { AdminPostProductsProductVariantsReq, Product } from "@medusajs/medusa"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import EditFlowVariantForm, {
EditFlowVariantFormType,
} from "../../../components/variant-form/edit-flow-variant-form"
import LayeredModal, {
LayeredModalContext,
} from "../../../../../components/molecules/modal/layered-modal"
import { useContext, useEffect } from "react"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import { useForm } from "react-hook-form"
import { useMedusa } from "medusa-react"
type Props = {
onClose: () => void
@@ -15,6 +20,8 @@ type Props = {
}
const AddVariantModal = ({ open, onClose, product }: Props) => {
const context = useContext(LayeredModalContext)
const { client } = useMedusa()
const form = useForm<EditFlowVariantFormType>({
defaultValues: getDefaultValues(product),
})
@@ -32,22 +39,64 @@ const AddVariantModal = ({ open, onClose, product }: Props) => {
onClose()
}
const createStockLocationsForVariant = async (
productRes,
stock_locations: { stocked_quantity: number; location_id: string }[]
) => {
const { variants } = productRes
const pvMap = new Map(product.variants.map((v) => [v.id, true]))
const addedVariant = variants.find((variant) => !pvMap.get(variant.id))
const inventory = await client.admin.variants.getInventory(addedVariant.id)
console.log(inventory)
await Promise.all(
inventory.variant.inventory
.map(async (item) => {
return Promise.all(
stock_locations.map(async (stock_location) => {
client.admin.inventoryItems.createLocationLevel(item.id!, {
location_id: stock_location.location_id,
stocked_quantity: stock_location.stocked_quantity,
})
})
)
})
.flat()
)
}
const onSubmit = handleSubmit((data) => {
onAddVariant(createAddPayload(data), resetAndClose)
const {
stock: { stock_location },
} = data
delete data.stock.stock_location
onAddVariant(createAddPayload(data), (productRes) => {
if (typeof stock_location !== "undefined") {
createStockLocationsForVariant(productRes, stock_location).then(() => {
resetAndClose()
})
} else {
resetAndClose()
}
})
})
return (
<Modal open={open} handleClose={resetAndClose}>
<LayeredModal context={context} open={open} handleClose={resetAndClose}>
<Modal.Body>
<Modal.Header handleClose={resetAndClose}>
<h1 className="inter-xlarge-semibold">Add Variant</h1>
</Modal.Header>
<form onSubmit={onSubmit}>
<Modal.Content>
<EditFlowVariantForm form={form} />
<EditFlowVariantForm isEdit={false} form={form} />
</Modal.Content>
<Modal.Footer>
<div className="flex items-center gap-x-xsmall justify-end w-full">
<div className="gap-x-xsmall flex w-full items-center justify-end">
<Button
variant="secondary"
size="small"
@@ -68,7 +117,7 @@ const AddVariantModal = ({ open, onClose, product }: Props) => {
</Modal.Footer>
</form>
</Modal.Body>
</Modal>
</LayeredModal>
)
}
@@ -92,6 +141,7 @@ const getDefaultValues = (product: Product): EditFlowVariantFormType => {
inventory_quantity: null,
manage_inventory: false,
allow_backorder: false,
stock_location: [],
},
options: options,
prices: {
@@ -0,0 +1,192 @@
import { Product, ProductVariant } from "@medusajs/medusa"
import {
useAdminUpdateLocationLevel,
useAdminVariantsInventory,
} from "medusa-react"
import React, { useContext } from "react"
import { useForm } from "react-hook-form"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import LayeredModal, {
LayeredModalContext,
} from "../../../../../components/molecules/modal/layered-modal"
import EditFlowVariantForm, {
EditFlowVariantFormType,
} from "../../../components/variant-inventory-form/edit-flow-variant-form"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import { createUpdatePayload } from "./edit-variants-modal/edit-variant-screen"
type Props = {
onClose: () => void
product: Product
variant: ProductVariant
isDuplicate?: boolean
}
const EditVariantInventoryModal = ({ onClose, product, variant }: Props) => {
const layeredModalContext = useContext(LayeredModalContext)
const {
// @ts-ignore
variant: variantInventory,
isLoading: isLoadingInventory,
refetch,
} = useAdminVariantsInventory(variant.id)
const itemId = variantInventory?.inventory[0]?.id
const { mutate: updateLocationLevel } = useAdminUpdateLocationLevel(
itemId || ""
)
const handleClose = () => {
onClose()
}
const { onUpdateVariant, updatingVariant } = useEditProductActions(product.id)
const onSubmit = async (data) => {
const { location_levels } = data.stock
await Promise.all(
location_levels.map(async (level) => {
await updateLocationLevel({
stockLocationId: level.location_id,
stocked_quantity: level.stocked_quantity,
})
})
)
// / TODO: Call update location level with new values
delete data.stock.location_levels
// @ts-ignore
onUpdateVariant(variant.id, createUpdatePayload(data), () => {
refetch()
handleClose()
})
}
return (
<LayeredModal context={layeredModalContext} handleClose={handleClose}>
<Modal.Header handleClose={handleClose}>
<h1 className="inter-xlarge-semibold">Edit stock & inventory</h1>
</Modal.Header>
{!isLoadingInventory && (
<StockForm
variantInventory={variantInventory}
refetchInventory={refetch}
onSubmit={onSubmit}
isLoadingInventory={isLoadingInventory}
handleClose={handleClose}
updatingVariant={updatingVariant}
/>
)}
</LayeredModal>
)
}
const StockForm = ({
variantInventory,
onSubmit,
refetchInventory,
isLoadingInventory,
handleClose,
updatingVariant,
}) => {
const form = useForm<EditFlowVariantFormType>({
defaultValues: getEditVariantDefaultValues(variantInventory),
})
const {
formState: { isDirty },
handleSubmit,
reset,
watch,
} = form
const locationLevels = watch("stock.location_levels")
const { location_levels } = variantInventory.inventory[0]
React.useEffect(() => {
form.setValue("stock.location_levels", location_levels)
}, [form, location_levels])
const handleOnSubmit = handleSubmit((data) => {
// @ts-ignore
onSubmit(data)
})
const itemId = variantInventory.inventory[0].id
return (
<form onSubmit={handleOnSubmit} noValidate>
<Modal.Content>
<EditFlowVariantForm
form={form}
refetchInventory={refetchInventory}
locationLevels={locationLevels || []}
itemId={itemId}
isLoading={isLoadingInventory}
/>
</Modal.Content>
<Modal.Footer>
<div className="flex items-center justify-end w-full gap-x-xsmall">
<Button
variant="secondary"
size="small"
type="button"
onClick={() => {
reset(getEditVariantDefaultValues(variantInventory))
handleClose()
}}
>
Cancel
</Button>
<Button
variant="primary"
size="small"
type="submit"
disabled={!isDirty}
loading={updatingVariant}
>
Save and close
</Button>
</div>
</Modal.Footer>
</form>
)
}
export const getEditVariantDefaultValues = (
variantInventory?: any
): EditFlowVariantFormType => {
const inventoryItem = variantInventory?.inventory[0]
if (!inventoryItem) {
return {
stock: {
sku: null,
ean: null,
inventory_quantity: null,
manage_inventory: false,
allow_backorder: false,
barcode: null,
upc: null,
location_levels: null,
},
}
}
return {
stock: {
sku: inventoryItem.sku,
ean: inventoryItem.ean,
inventory_quantity: inventoryItem.inventory_quantity,
manage_inventory: !!inventoryItem,
allow_backorder: inventoryItem.allow_backorder,
barcode: inventoryItem.barcode,
upc: inventoryItem.upc,
location_levels: inventoryItem.location_levels,
},
}
}
export default EditVariantInventoryModal
@@ -1,14 +1,20 @@
import { Product, ProductVariant } from "@medusajs/medusa"
import { useForm } from "react-hook-form"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import { countries } from "../../../../../utils/countries"
import EditFlowVariantForm, {
EditFlowVariantFormType,
} from "../../../components/variant-form/edit-flow-variant-form"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import LayeredModal, {
LayeredModalContext,
} from "../../../../../components/molecules/modal/layered-modal"
import { Product, ProductVariant } from "@medusajs/medusa"
import Button from "../../../../../components/fundamentals/button"
import Modal from "../../../../../components/molecules/modal"
import { countries } from "../../../../../utils/countries"
import { createAddPayload } from "./add-variant-modal"
import { createUpdatePayload } from "./edit-variants-modal/edit-variant-screen"
import { useContext } from "react"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import { useForm } from "react-hook-form"
import { useMedusa } from "medusa-react"
type Props = {
onClose: () => void
@@ -24,6 +30,7 @@ const EditVariantModal = ({
isDuplicate = false,
}: Props) => {
const form = useForm<EditFlowVariantFormType>({
// @ts-ignore
defaultValues: getEditVariantDefaultValues(variant, product),
})
@@ -41,22 +48,67 @@ const EditVariantModal = ({
const { onUpdateVariant, onAddVariant, addingVariant, updatingVariant } =
useEditProductActions(product.id)
const { client } = useMedusa()
const createStockLocationsForVariant = async (
productRes,
stock_locations: { stocked_quantity: number; location_id: string }[]
) => {
const { variants } = productRes
const pvMap = new Map(product.variants.map((v) => [v.id, true]))
const addedVariant = variants.find((variant) => !pvMap.get(variant.id))
const inventory = await client.admin.variants.getInventory(addedVariant.id)
await Promise.all(
inventory.variant.inventory
.map(async (item) => {
return Promise.all(
stock_locations.map(async (stock_location) => {
client.admin.inventoryItems.createLocationLevel(item.id!, {
location_id: stock_location.location_id,
stocked_quantity: stock_location.stocked_quantity,
})
})
)
})
.flat()
)
}
const onSubmit = handleSubmit((data) => {
const {
stock: { stock_location },
} = data
delete data.stock.stock_location
if (isDuplicate) {
onAddVariant(createAddPayload(data), handleClose)
onAddVariant(createAddPayload(data), (productRes) => {
if (typeof stock_location !== "undefined") {
createStockLocationsForVariant(productRes, stock_location).then(
() => {
handleClose()
}
)
} else {
handleClose()
}
})
} else {
// @ts-ignore
onUpdateVariant(variant.id, createUpdatePayload(data), handleClose)
}
})
const layeredModalContext = useContext(LayeredModalContext)
return (
<Modal handleClose={handleClose}>
<LayeredModal context={layeredModalContext} handleClose={handleClose}>
<Modal.Header handleClose={handleClose}>
<h1 className="inter-xlarge-semibold">
Edit Variant
{variant.title && (
<span className="text-grey-50 inter-xlarge-regular">
<span className="inter-xlarge-regular text-grey-50">
{" "}
({variant.title})
</span>
@@ -65,10 +117,10 @@ const EditVariantModal = ({
</Modal.Header>
<form onSubmit={onSubmit} noValidate>
<Modal.Content>
<EditFlowVariantForm form={form} />
<EditFlowVariantForm isEdit={true} form={form} />
</Modal.Content>
<Modal.Footer>
<div className="w-full flex items-center gap-x-xsmall justify-end">
<div className="gap-x-xsmall flex w-full items-center justify-end">
<Button
variant="secondary"
size="small"
@@ -89,7 +141,7 @@ const EditVariantModal = ({
</div>
</Modal.Footer>
</form>
</Modal>
</LayeredModal>
)
}
@@ -3,17 +3,18 @@ import {
Product,
ProductVariant,
} from "@medusajs/medusa"
import React, { useContext, useEffect, useMemo } from "react"
import { useForm } from "react-hook-form"
import Button from "../../../../../../components/fundamentals/button"
import Modal from "../../../../../../components/molecules/modal"
import { LayeredModalContext } from "../../../../../../components/molecules/modal/layered-modal"
import EditFlowVariantForm, {
EditFlowVariantFormType,
} from "../../../../components/variant-form/edit-flow-variant-form"
import useEditProductActions from "../../../hooks/use-edit-product-actions"
import React, { useContext, useEffect, useMemo } from "react"
import Button from "../../../../../../components/fundamentals/button"
import { LayeredModalContext } from "../../../../../../components/molecules/modal/layered-modal"
import Modal from "../../../../../../components/molecules/modal"
import { getEditVariantDefaultValues } from "../edit-variant-modal"
import useEditProductActions from "../../../hooks/use-edit-product-actions"
import { useEditVariantsModal } from "./use-edit-variants-modal"
import { useForm } from "react-hook-form"
type Props = {
variant: ProductVariant
@@ -58,10 +59,10 @@ const EditVariantScreen = ({ variant, product }: Props) => {
<>
<form noValidate>
<Modal.Content>
<EditFlowVariantForm form={form} />
<EditFlowVariantForm isEdit={true} form={form} />
</Modal.Content>
<Modal.Footer>
<div className="flex items-center w-full justify-end gap-x-xsmall">
<div className="gap-x-xsmall flex w-full items-center justify-end">
<Button variant="secondary" size="small" type="button">
Cancel
</Button>
@@ -97,7 +98,7 @@ export const createUpdatePayload = (
): AdminPostProductsProductVariantsVariantReq => {
const { customs, dimensions, prices, options, general, stock } = data
const priceArray = prices.prices
const priceArray = prices?.prices
.filter((price) => typeof price.amount === "number")
.map((price) => {
return {
@@ -109,19 +110,18 @@ export const createUpdatePayload = (
})
return {
// @ts-ignore
...general,
...customs,
...stock,
...dimensions,
...customs,
// @ts-ignore
origin_country: customs.origin_country
origin_country: customs?.origin_country
? customs.origin_country.value
: null,
// @ts-ignore
prices: priceArray,
options: options.map((option) => ({
options: options?.map((option) => ({
option_id: option.id,
value: option.value,
})),
@@ -1,26 +1,39 @@
import OptionsProvider, { useOptionsContext } from "./options-provider"
import { Product, ProductVariant } from "@medusajs/medusa"
import React, { useState } from "react"
import EditIcon from "../../../../../components/fundamentals/icons/edit-icon"
import GearIcon from "../../../../../components/fundamentals/icons/gear-icon"
import PlusIcon from "../../../../../components/fundamentals/icons/plus-icon"
import { ActionType } from "../../../../../components/molecules/actionables"
import Section from "../../../../../components/organisms/section"
import useToggleState from "../../../../../hooks/use-toggle-state"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import AddVariantModal from "./add-variant-modal"
import EditIcon from "../../../../../components/fundamentals/icons/edit-icon"
import EditVariantInventoryModal from "./edit-variant-inventory-modal"
import EditVariantModal from "./edit-variant-modal"
import EditVariantsModal from "./edit-variants-modal"
import GearIcon from "../../../../../components/fundamentals/icons/gear-icon"
import OptionsModal from "./options-modal"
import OptionsProvider, { useOptionsContext } from "./options-provider"
import PlusIcon from "../../../../../components/fundamentals/icons/plus-icon"
import Section from "../../../../../components/organisms/section"
import VariantsTable from "./table"
import useEditProductActions from "../../hooks/use-edit-product-actions"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
import { useState } from "react"
import useToggleState from "../../../../../hooks/use-toggle-state"
type Props = {
product: Product
}
const VariantsSection = ({ product }: Props) => {
const { isFeatureEnabled } = useFeatureFlag()
const [variantToEdit, setVariantToEdit] = useState<
{ base: ProductVariant; isDuplicate: boolean } | undefined
| {
base: ProductVariant
isDuplicate: boolean
}
| undefined
>(undefined)
const [variantInventoryToEdit, setVariantInventoryToEdit] = useState<
{ base: ProductVariant } | undefined
>(undefined)
const {
@@ -74,6 +87,10 @@ const VariantsSection = ({ product }: Props) => {
setVariantToEdit({ base: { ...variant, options: [] }, isDuplicate: true })
}
const handleEditVariantInventory = (variant: ProductVariant) => {
setVariantInventoryToEdit({ base: variant })
}
return (
<OptionsProvider product={product}>
<Section title="Variants" actions={actions}>
@@ -91,6 +108,7 @@ const VariantsSection = ({ product }: Props) => {
deleteVariant: handleDeleteVariant,
updateVariant: handleEditVariant,
duplicateVariant: handleDuplicateVariant,
updateVariantInventory: handleEditVariantInventory,
}}
/>
</div>
@@ -118,6 +136,13 @@ const VariantsSection = ({ product }: Props) => {
onClose={() => setVariantToEdit(undefined)}
/>
)}
{variantInventoryToEdit && (
<EditVariantInventoryModal
variant={variantInventoryToEdit.base}
product={product}
onClose={() => setVariantInventoryToEdit(undefined)}
/>
)}
</OptionsProvider>
)
}
@@ -135,11 +160,11 @@ const ProductOptions = () => {
{Array.from(Array(2)).map((_, i) => {
return (
<div key={i}>
<div className="bg-grey-30 h-6 w-9 animate-pulse mb-xsmall"></div>
<div className="mb-xsmall bg-grey-30 h-6 w-9 animate-pulse"></div>
<ul className="flex flex-wrap items-center gap-1">
{Array.from(Array(3)).map((_, j) => (
<li key={j}>
<div className="text-grey-50 bg-grey-10 h-8 w-12 animate-pulse rounded-rounded">
<div className="rounded-rounded bg-grey-10 text-grey-50 h-8 w-12 animate-pulse">
{j}
</div>
</li>
@@ -153,7 +178,7 @@ const ProductOptions = () => {
}
return (
<div className="mt-base flex items-center flex-wrap gap-8">
<div className="mt-base flex flex-wrap items-center gap-8">
{options.map((option) => {
return (
<div key={option.id}>
@@ -164,7 +189,7 @@ const ProductOptions = () => {
.filter((v, index, self) => self.indexOf(v) === index)
.map((v, i) => (
<li key={i}>
<div className="text-grey-50 bg-grey-10 inter-small-semibold px-3 py-[6px] rounded-rounded whitespace-nowrap">
<div className="inter-small-semibold rounded-rounded bg-grey-10 text-grey-50 whitespace-nowrap px-3 py-[6px]">
{v}
</div>
</li>
@@ -1,11 +1,14 @@
import { ProductVariant } from "@medusajs/medusa"
import { useMemo } from "react"
import { Column, useTable } from "react-table"
import Actionables from "../../../../../components/molecules/actionables"
import BuildingsIcon from "../../../../../components/fundamentals/icons/buildings-icon"
import DuplicateIcon from "../../../../../components/fundamentals/icons/duplicate-icon"
import EditIcon from "../../../../../components/fundamentals/icons/edit-icon"
import TrashIcon from "../../../../../components/fundamentals/icons/trash-icon"
import Actionables from "../../../../../components/molecules/actionables"
import { ProductVariant } from "@medusajs/medusa"
import Table from "../../../../../components/molecules/table"
import TrashIcon from "../../../../../components/fundamentals/icons/trash-icon"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
import { useMemo } from "react"
type Props = {
variants: ProductVariant[]
@@ -13,12 +16,35 @@ type Props = {
deleteVariant: (variantId: string) => void
duplicateVariant: (variant: ProductVariant) => void
updateVariant: (variant: ProductVariant) => void
updateVariantInventory: (variant: ProductVariant) => void
}
}
export const useVariantsTableColumns = () => {
const columns = useMemo<Column<ProductVariant>[]>(
() => [
export const useVariantsTableColumns = (inventoryIsEnabled = false) => {
const columns = useMemo<Column<ProductVariant>[]>(() => {
const quantityColumns = []
if (!inventoryIsEnabled) {
quantityColumns.push({
Header: () => {
return (
<div className="text-right">
<span>Inventory</span>
</div>
)
},
id: "inventory",
accessor: "inventory_quantity",
maxWidth: 56,
Cell: ({ cell }) => {
return (
<div className="text-right">
<span>{cell.value}</span>
</div>
)
},
})
}
return [
{
Header: "Title",
id: "title",
@@ -50,34 +76,17 @@ export const useVariantsTableColumns = () => {
)
},
},
{
Header: () => {
return (
<div className="text-right">
<span>Inventory</span>
</div>
)
},
id: "inventory",
accessor: "inventory_quantity",
maxWidth: 56,
Cell: ({ cell }) => {
return (
<div className="text-right">
<span>{cell.value}</span>
</div>
)
},
},
],
[]
)
...quantityColumns,
]
}, [inventoryIsEnabled])
return columns
}
const VariantsTable = ({ variants, actions }: Props) => {
const columns = useVariantsTableColumns()
const { isFeatureEnabled } = useFeatureFlag()
const hasInventoryService = isFeatureEnabled("inventoryService")
const columns = useVariantsTableColumns(hasInventoryService)
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
useTable({
@@ -88,61 +97,86 @@ const VariantsTable = ({ variants, actions }: Props) => {
},
})
const { deleteVariant, updateVariant, duplicateVariant } = actions
const {
deleteVariant,
updateVariant,
duplicateVariant,
updateVariantInventory,
} = actions
const getTableRowActionables = (variant: ProductVariant) => {
const inventoryManagementActions = []
if (hasInventoryService && variant.manage_inventory) {
inventoryManagementActions.push({
label: "Manage inventory", // TODO: Only add this item if variant.manageInventory is true
icon: <BuildingsIcon size="20" />,
onClick: () => updateVariantInventory(variant),
})
}
return [
{
label: "Edit Variant",
icon: <EditIcon size="20" />,
onClick: () => updateVariant(variant),
},
...inventoryManagementActions,
{
label: "Duplicate Variant",
onClick: () =>
// @ts-ignore
duplicateVariant({
...variant,
title: variant.title + " Copy",
}),
icon: <DuplicateIcon size="20" />,
},
{
label: "Delete Variant",
onClick: () => deleteVariant(variant.id),
icon: <TrashIcon size="20" />,
variant: "danger",
},
]
}
return (
<Table {...getTableProps()} className="table-fixed">
<Table.Head>
{headerGroups?.map((headerGroup) => (
<Table.HeadRow {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((col) => (
<Table.HeadCell {...col.getHeaderProps()}>
{col.render("Header")}
</Table.HeadCell>
))}
</Table.HeadRow>
))}
{headerGroups?.map((headerGroup) => {
const { key, ...rest } = headerGroup.getHeaderGroupProps()
return (
<Table.HeadRow key={key} {...rest}>
{headerGroup.headers.map((col) => {
const { key, ...rest } = col.getHeaderProps()
return (
<Table.HeadCell key={key} {...rest}>
{col.render("Header")}
</Table.HeadCell>
)
})}
</Table.HeadRow>
)
})}
</Table.Head>
<Table.Body {...getTableBodyProps()}>
{rows.map((row) => {
prepareRow(row)
const actionables = getTableRowActionables(row.original)
const { key, ...rest } = row.getRowProps()
return (
<Table.Row color={"inherit"} {...row.getRowProps()}>
<Table.Row color={"inherit"} key={key} {...rest}>
{row.cells.map((cell) => {
const { key, ...rest } = cell.getCellProps()
return (
<Table.Cell {...cell.getCellProps()}>
<Table.Cell key={key} {...rest}>
{cell.render("Cell")}
</Table.Cell>
)
})}
<Table.Cell>
<div className="float-right">
<Actionables
forceDropdown
actions={[
{
label: "Edit Variant",
icon: <EditIcon size="20" />,
onClick: () => updateVariant(row.original),
},
{
label: "Duplicate Variant",
onClick: () =>
// @ts-ignore
duplicateVariant({
...row.original,
title: row.original.title + " Copy",
}),
icon: <DuplicateIcon size="20" />,
},
{
label: "Delete Variant",
onClick: () => deleteVariant(row.original.id),
icon: <TrashIcon size="20" />,
variant: "danger",
},
]}
/>
<Actionables forceDropdown actions={actionables} />
</div>
</Table.Cell>
</Table.Row>
@@ -1,5 +1,5 @@
import clsx from "clsx"
import React, { useCallback, useEffect, useMemo } from "react"
import { useCallback, useContext, useEffect, useMemo } from "react"
import { Controller, useFieldArray, useForm, useWatch } from "react-hook-form"
import { v4 as uuidv4 } from "uuid"
import Button from "../../../../components/fundamentals/button"
@@ -8,6 +8,9 @@ import TrashIcon from "../../../../components/fundamentals/icons/trash-icon"
import IconTooltip from "../../../../components/molecules/icon-tooltip"
import InputField from "../../../../components/molecules/input"
import Modal from "../../../../components/molecules/modal"
import LayeredModal, {
LayeredModalContext,
} from "../../../../components/molecules/modal/layered-modal"
import TagInput from "../../../../components/molecules/tag-input"
import { useDebounce } from "../../../../hooks/use-debounce"
import useToggleState from "../../../../hooks/use-toggle-state"
@@ -43,6 +46,7 @@ const AddVariantsForm = ({
productCustoms,
productDimensions,
}: Props) => {
const layeredModalContext = useContext(LayeredModalContext)
const { control, path, register } = form
const { checkForDuplicate, getOptions } = useCheckOptions(form)
@@ -267,7 +271,7 @@ const AddVariantsForm = ({
<div>
{options.length > 0 && (
<div className="mt-small">
<div className="grid grid-cols-[230px_1fr_40px] gap-x-xsmall inter-small-semibold text-grey-50 mb-small">
<div className="inter-small-semibold mb-small gap-x-xsmall text-grey-50 grid grid-cols-[230px_1fr_40px]">
<span>Option title</span>
<span>Variations (comma separated)</span>
</div>
@@ -276,7 +280,7 @@ const AddVariantsForm = ({
return (
<div
key={field.fieldId}
className="grid grid-cols-[230px_1fr_40px] gap-x-xsmall"
className="gap-x-xsmall grid grid-cols-[230px_1fr_40px]"
>
<InputField
placeholder="Color..."
@@ -322,7 +326,7 @@ const AddVariantsForm = ({
<Button
variant="secondary"
size="small"
className="h-10 w-full mt-base"
className="w-full h-10 mt-base"
type="button"
onClick={appendNewOption}
>
@@ -350,7 +354,7 @@ const AddVariantsForm = ({
</div>
{variants?.length > 0 && (
<div className="mt-small">
<div className="grid grid-cols-[1fr_90px_100px_48px] inter-small-semibold text-grey-50 pr-base">
<div className="inter-small-semibold pr-base text-grey-50 grid grid-cols-[1fr_90px_100px_48px]">
<p>Variant</p>
<div className="flex justify-end mr-xlarge">
<p>Inventory</p>
@@ -380,7 +384,7 @@ const AddVariantsForm = ({
<Button
variant="secondary"
size="small"
className="h-10 w-full mt-base"
className="w-full h-10 mt-base"
type="button"
disabled={!enableVariants}
onClick={onToggleForm}
@@ -392,7 +396,11 @@ const AddVariantsForm = ({
</div>
</div>
<Modal open={state} handleClose={onToggleForm}>
<LayeredModal
context={layeredModalContext}
open={state}
handleClose={onToggleForm}
>
<Modal.Body>
<Modal.Header handleClose={onToggleForm}>
<h1 className="inter-xlarge-semibold">Create Variant</h1>
@@ -405,7 +413,7 @@ const AddVariantsForm = ({
/>
</Modal.Content>
<Modal.Footer>
<div className="flex items-center gap-x-xsmall justify-end w-full">
<div className="flex items-center justify-end w-full gap-x-xsmall">
<Button
variant="secondary"
size="small"
@@ -425,7 +433,7 @@ const AddVariantsForm = ({
</div>
</Modal.Footer>
</Modal.Body>
</Modal>
</LayeredModal>
</>
)
}
@@ -1,6 +1,6 @@
import clsx from "clsx"
import type { Identifier, XYCoord } from "dnd-core"
import React, { useEffect, useRef } from "react"
import { useContext, useEffect, useRef } from "react"
import { useDrag, useDrop } from "react-dnd"
import { useForm } from "react-hook-form"
import Tooltip from "../../../../../components/atoms/tooltip"
@@ -13,6 +13,9 @@ import TrashIcon from "../../../../../components/fundamentals/icons/trash-icon"
import Actionables from "../../../../../components/molecules/actionables"
import IconTooltip from "../../../../../components/molecules/icon-tooltip"
import Modal from "../../../../../components/molecules/modal"
import LayeredModal, {
LayeredModalContext,
} from "../../../../../components/molecules/modal/layered-modal"
import useImperativeDialog from "../../../../../hooks/use-imperative-dialog"
import useToggleState from "../../../../../hooks/use-toggle-state"
import { DragItem } from "../../../../../types/shared"
@@ -156,6 +159,7 @@ const NewVariant = ({
})
drag(drop(ref))
const layeredModalContext = useContext(LayeredModalContext)
return (
<>
@@ -163,7 +167,7 @@ const NewVariant = ({
ref={preview}
data-handler-id={handlerId}
className={clsx(
"grid grid-cols-[32px_1fr_90px_100px_48px] transition-all rounded-rounded hover:bg-grey-5 focus-within:bg-grey-5 h-16 py-xsmall pl-xsmall pr-base translate-y-0 translate-x-0",
"rounded-rounded py-xsmall pl-xsmall pr-base focus-within:bg-grey-5 hover:bg-grey-5 grid h-16 translate-y-0 translate-x-0 grid-cols-[32px_1fr_90px_100px_48px] transition-all",
{
"opacity-50": isDragging,
}
@@ -171,15 +175,15 @@ const NewVariant = ({
>
<div
ref={ref}
className="text-grey-40 cursor-move flex items-center justify-center"
className="flex items-center justify-center cursor-move text-grey-40"
>
<GripIcon size={20} />
</div>
<div className="flex justify-center flex-col ml-base">
<div className="flex flex-col justify-center ml-base">
<p className="inter-base-semibold">
{source.general.title}
{source.stock.sku && (
<span className="inter-base-regular text-grey-50 ml-2xsmall">
<span className="inter-base-regular ml-2xsmall text-grey-50">
({source.stock.sku})
</span>
)}
@@ -200,7 +204,7 @@ const NewVariant = ({
productDimensions={productDimensions}
/>
</div>
<div className="ml-xlarge flex items-center justify-center pr-base">
<div className="flex items-center justify-center ml-xlarge pr-base">
<Actionables
forceDropdown
actions={[
@@ -219,7 +223,7 @@ const NewVariant = ({
customTrigger={
<Button
variant="ghost"
className="w-xlarge h-xlarge p-0 flex items-center justify-center text-grey-50"
className="flex items-center justify-center p-0 h-xlarge w-xlarge text-grey-50"
>
<MoreHorizontalIcon size={20} />
</Button>
@@ -227,14 +231,17 @@ const NewVariant = ({
/>
</div>
</div>
<Modal open={state} handleClose={closeAndReset}>
<LayeredModal
context={layeredModalContext}
open={state}
handleClose={closeAndReset}
>
<Modal.Body>
<Modal.Header handleClose={closeAndReset}>
<h1 className="inter-xlarge-semibold">
Edit Variant
{source.general.title && (
<span className="ml-xsmall inter-xlarge-regular text-grey-50">
<span className="inter-xlarge-regular ml-xsmall text-grey-50">
({source.general.title})
</span>
)}
@@ -248,7 +255,7 @@ const NewVariant = ({
/>
</Modal.Content>
<Modal.Footer>
<div className="flex items-center gap-x-xsmall justify-end w-full">
<div className="flex items-center justify-end w-full gap-x-xsmall">
<Button
variant="secondary"
size="small"
@@ -268,7 +275,7 @@ const NewVariant = ({
</div>
</Modal.Footer>
</Modal.Body>
</Modal>
</LayeredModal>
</>
)
}
@@ -292,7 +299,7 @@ const VariantValidity = ({
<IconTooltip
type="error"
content={
<div className="text-rose-50 flex flex-col gap-y-2xsmall">
<div className="flex flex-col gap-y-2xsmall text-rose-50">
<p>This variant has no options.</p>
</div>
}
@@ -307,7 +314,7 @@ const VariantValidity = ({
<IconTooltip
type="error"
content={
<div className="text-rose-50 flex flex-col gap-y-2xsmall">
<div className="flex flex-col gap-y-2xsmall text-rose-50">
<p>You are missing options values for the following options:</p>
<ul className="list-disc list-inside">
{invalidOptions.map((io, index) => {
@@ -343,7 +350,7 @@ const VariantValidity = ({
type="warning"
side="right"
content={
<div className="text-orange-50 flex flex-col gap-y-2xsmall">
<div className="flex flex-col gap-y-2xsmall text-orange-50">
<p>
Your variant is createable, but it's missing some important
fields:
@@ -1,19 +1,8 @@
import { AdminPostProductsReq } from "@medusajs/medusa"
import { useAdminCreateProduct } from "medusa-react"
import { useEffect } from "react"
import { useForm, useWatch } from "react-hook-form"
import { useNavigate } from "react-router-dom"
import Button from "../../../components/fundamentals/button"
import FeatureToggle from "../../../components/fundamentals/feature-toggle"
import CrossIcon from "../../../components/fundamentals/icons/cross-icon"
import FocusModal from "../../../components/molecules/modal/focus-modal"
import Accordion from "../../../components/organisms/accordion"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import useNotification from "../../../hooks/use-notification"
import { FormImage, ProductStatus } from "../../../types/shared"
import { getErrorMessage } from "../../../utils/error-messages"
import { prepareImages } from "../../../utils/images"
import { nestedForm } from "../../../utils/nested-form"
import AddSalesChannelsForm, {
AddSalesChannelsFormType,
} from "./add-sales-channels"
import AddVariantsForm, { AddVariantsFormType } from "./add-variants"
import { AdminPostProductsReq, ProductVariant } from "@medusajs/medusa"
import CustomsForm, { CustomsFormType } from "../components/customs-form"
import DimensionsForm, {
DimensionsFormType,
@@ -21,15 +10,27 @@ import DimensionsForm, {
import DiscountableForm, {
DiscountableFormType,
} from "../components/discountable-form"
import { FormImage, ProductStatus } from "../../../types/shared"
import GeneralForm, { GeneralFormType } from "../components/general-form"
import MediaForm, { MediaFormType } from "../components/media-form"
import OrganizeForm, { OrganizeFormType } from "../components/organize-form"
import { PricesFormType } from "../components/prices-form"
import ThumbnailForm, { ThumbnailFormType } from "../components/thumbnail-form"
import AddSalesChannelsForm, {
AddSalesChannelsFormType,
} from "./add-sales-channels"
import AddVariantsForm, { AddVariantsFormType } from "./add-variants"
import { useAdminCreateProduct, useMedusa } from "medusa-react"
import { useForm, useWatch } from "react-hook-form"
import Accordion from "../../../components/organisms/accordion"
import Button from "../../../components/fundamentals/button"
import CrossIcon from "../../../components/fundamentals/icons/cross-icon"
import FeatureToggle from "../../../components/fundamentals/feature-toggle"
import FocusModal from "../../../components/molecules/modal/focus-modal"
import { PricesFormType } from "../components/prices-form"
import { getErrorMessage } from "../../../utils/error-messages"
import { nestedForm } from "../../../utils/nested-form"
import { prepareImages } from "../../../utils/images"
import { useEffect } from "react"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import { useNavigate } from "react-router-dom"
import useNotification from "../../../hooks/use-notification"
type NewProductForm = {
general: GeneralFormType
@@ -84,6 +85,18 @@ const NewProduct = ({ onClose }: Props) => {
const onSubmit = (publish = true) =>
handleSubmit(async (data) => {
const optionsToStockLocationsMap = new Map(
data.variants.entries.map((variant) => {
return [
variant.options
.map(({ option }) => option?.value || "")
.sort()
.join(","),
variant.stock.stock_location,
]
})
)
const payload = createPayload(
data,
publish,
@@ -142,8 +155,13 @@ const NewProduct = ({ onClose }: Props) => {
mutate(payload, {
onSuccess: ({ product }) => {
closeAndReset()
navigate(`/a/products/${product.id}`)
createStockLocationsForVariants(
product.variants,
optionsToStockLocationsMap
).then(() => {
closeAndReset()
navigate(`/a/products/${product.id}`)
})
},
onError: (err) => {
notification("Error", getErrorMessage(err), "error")
@@ -151,6 +169,49 @@ const NewProduct = ({ onClose }: Props) => {
})
})
const { client } = useMedusa()
const createStockLocationsForVariants = async (
variants: ProductVariant[],
stockLocationsMap: Map<
string,
{ stocked_quantity: number; location_id: string }[] | undefined
>
) => {
await Promise.all(
variants
.map(async (variant) => {
const optionsKey = variant.options
.map((option) => option?.value || "")
.sort()
.join(",")
const stock_locations = stockLocationsMap.get(optionsKey)
if (!stock_locations?.length) {
return
}
const inventory = await client.admin.variants.getInventory(variant.id)
return await Promise.all(
inventory.variant.inventory
.map(async (item) => {
return Promise.all(
stock_locations.map(async (stock_location) => {
client.admin.inventoryItems.createLocationLevel(item.id!, {
location_id: stock_location.location_id,
stocked_quantity: stock_location.stocked_quantity,
})
})
)
})
.flat()
)
})
.flat()
)
}
return (
<form className="w-full">
<FocusModal>
@@ -1,10 +1,10 @@
import { useAdminGetSession, useAdminStore } from "medusa-react"
import React, {
PropsWithChildren,
useContext,
useEffect,
useState,
} from "react"
import { useAdminGetSession, useAdminStore } from "medusa-react"
export enum FeatureFlag {
PRODUCT_CATEGORIES = "product_categories",
@@ -2,6 +2,9 @@ import {
AdminGetVariantsVariantInventoryRes,
AdminGetVariantsParams,
AdminVariantsListRes,
StoreGetVariantsVariantParams,
AdminGetVariantParams,
AdminVariantsRes,
} from "@medusajs/medusa"
import qs from "qs"
import { ResponsePromise } from "../.."
@@ -28,6 +31,27 @@ class AdminVariantsResource extends BaseResource {
return this.client.request("GET", path, undefined, {}, customHeaders)
}
/**
* Get a product variant
* @param id Query to filter variants by
* @param customHeaders custom headers
* @returns A list of variants satisfying the criteria of the query
*/
retrieve(
id: string,
query?: AdminGetVariantParams,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminVariantsRes> {
let path = `/admin/variants/${id}`
if (query) {
const queryString = qs.stringify(query)
path = `/admin/variants?${queryString}`
}
return this.client.request("GET", path, undefined, {}, customHeaders)
}
/**
*
* @param variantId id of the variant to fetch inventory for
@@ -1481,6 +1481,16 @@ export const adminHandlers = [
)
}),
rest.get("/admin/variants/:id", (req, res, ctx) => {
const { id } = req.params
return res(
ctx.status(200),
ctx.json({
variant: {...fixtures.get("product_variant"), id: id},
})
)
}),
rest.get("/admin/variants/:id/inventory", (req, res, ctx) => {
return res(
ctx.status(200),
@@ -1,7 +1,9 @@
import {
AdminGetVariantParams,
AdminGetVariantsParams,
AdminGetVariantsVariantInventoryRes,
AdminVariantsListRes,
AdminVariantsRes,
} from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { useQuery } from "@tanstack/react-query"
@@ -32,6 +34,24 @@ export const useAdminVariants = (
return { ...data, ...rest } as const
}
export const useAdminVariant = (
id: string,
query?: AdminGetVariantParams,
options?: UseQueryOptionsWrapper<
Response<AdminVariantsRes>,
Error,
ReturnType<VariantQueryKeys["detail"]>
>
) => {
const { client } = useMedusa()
const { data, ...rest } = useQuery(
adminVariantKeys.detail(id),
() => client.admin.variants.retrieve(id, query),
options
)
return { ...data, ...rest } as const
}
export const useAdminVariantsInventory = (
id: string,
options?: UseQueryOptionsWrapper<
@@ -1,7 +1,8 @@
import { renderHook } from "@testing-library/react-hooks/dom"
import { fixtures } from "../../../../mocks/data"
import { useAdminVariants, useAdminVariantsInventory } from "../../../../src"
import { useAdminVariant, useAdminVariants, useAdminVariantsInventory } from "../../../../src"
import { createWrapper } from "../../../utils"
import { fixtures } from "../../../../mocks/data"
import { renderHook } from "@testing-library/react-hooks"
describe("useAdminVariants hook", () => {
test("returns a list of variants", async () => {
@@ -17,6 +18,20 @@ describe("useAdminVariants hook", () => {
})
})
describe("useAdminVariant hook", () => {
test("returns a variant", async () => {
const variant = fixtures.get("product_variant")
const { result, waitFor } = renderHook(() => useAdminVariant(variant.id), {
wrapper: createWrapper(),
})
await waitFor(() => result.current.isSuccess)
expect(result.current.response.status).toEqual(200)
expect(result.current.variant).toEqual(variant)
})
})
describe("useAdminVariants hook", () => {
test("returns a variant with saleschannel locations", async () => {
const variant = fixtures.get("product_variant")
@@ -3,10 +3,7 @@ import {
InventoryItemDTO,
InventoryLevelDTO,
} from "../../../../../types/inventory"
type LevelWithAvailability = InventoryLevelDTO & {
available_quantity: number
}
import { LevelWithAvailability, ResponseInventoryItem } from "../../variants"
export const buildLevelsByInventoryItemId = (
inventoryLevels: InventoryLevelDTO[],
@@ -27,7 +24,7 @@ export const getLevelsByInventoryItemId = async (
items: InventoryItemDTO[],
locationIds: string[],
inventoryService: IInventoryService
) => {
): Promise<Record<string, LevelWithAvailability[]>> => {
const [levels] = await inventoryService.listInventoryLevels({
inventory_item_id: items.map((inventoryItem) => inventoryItem.id),
})
@@ -52,7 +49,7 @@ export const joinLevels = async (
inventoryItems: InventoryItemDTO[],
locationIds: string[],
inventoryService: IInventoryService
) => {
): Promise<ResponseInventoryItem[]> => {
const levelsByItemId = await getLevelsByInventoryItemId(
inventoryItems,
locationIds,
@@ -151,8 +151,23 @@ type SalesChannelDTO = Omit<SalesChannel, "beforeInsert" | "locations"> & {
locations: string[]
}
type ResponseInventoryItem = Partial<InventoryItemDTO> & {
location_levels?: InventoryLevelDTO[]
export type LevelWithAvailability = InventoryLevelDTO & {
available_quantity: number
}
/**
* @schema ResponseInventoryItem
* allOf:
* - $ref: "#/components/schemas/InventoryItemDTO"
* - type: object
* required:
* - available_quantity
* properties:
* available_quantity:
* type: number
*/
export type ResponseInventoryItem = Partial<InventoryItemDTO> & {
location_levels?: LevelWithAvailability[]
}
/**
@@ -164,7 +179,7 @@ type ResponseInventoryItem = Partial<InventoryItemDTO> & {
* type: string
* inventory:
* description: the stock location address ID
* type: string
* $ref: "#/components/schemas/ResponseInventoryItem"
* sales_channel_availability:
* type: object
* description: An optional key-value map with additional details
@@ -0,0 +1,73 @@
import { PricingService, ProductVariantService } from "../../../../services"
import { FindParams } from "../../../../types/common"
/**
* @oas [get] /admin/variants/{id}
* operationId: "GetVariantsVariant"
* summary: "Get a Product variant"
* description: "Retrieves a Product variant."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the variant.
* x-codegen:
* method: retrieve
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.variants.retrieve(product_id)
* .then(({ product }) => {
* console.log(product.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request GET 'https://medusa-url.com/admin/variants/{id}' \
* --header 'Authorization: Bearer {api_token}'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Products
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/AdminVariantRes"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req, res) => {
const { id } = req.params
const productVariantService: ProductVariantService = req.scope.resolve(
"productVariantService"
)
const pricingService: PricingService = req.scope.resolve("pricingService")
const rawVariant = await productVariantService.retrieve(
id,
req.retrieveConfig
)
const [variant] = await pricingService.setVariantPrices([rawVariant])
res.status(200).json({ variant })
}
export class AdminGetVariantParams extends FindParams {}
@@ -5,6 +5,7 @@ import { PaginatedResponse } from "../../../../types/common"
import { PricedVariant } from "../../../../types/pricing"
import middlewares, { transformQuery } from "../../../middlewares"
import { checkRegisteredModules } from "../../../middlewares/check-registered-modules"
import { AdminGetVariantParams } from "./get-variant"
import { AdminGetVariantsParams } from "./list-variants"
const route = Router()
@@ -22,6 +23,16 @@ export default (app) => {
middlewares.wrap(require("./list-variants").default)
)
route.get(
"/:id",
transformQuery(AdminGetVariantParams, {
defaultRelations: defaultAdminVariantRelations,
defaultFields: defaultAdminVariantFields,
isList: false,
}),
middlewares.wrap(require("./get-variant").default)
)
route.get(
"/:id/inventory",
checkRegisteredModules({
@@ -88,5 +99,19 @@ export type AdminVariantsListRes = PaginatedResponse & {
variants: PricedVariant[]
}
/**
* @schema AdminVariantRes
* type: object
* required:
* - variant
* properties:
* variant:
* $ref: "#/components/schemas/PricedVariant"
*/
export type AdminVariantsRes = {
variant: PricedVariant
}
export * from "./list-variants"
export * from "./get-variant"
export * from "./get-inventory"
+1 -1
View File
@@ -423,7 +423,7 @@ class PricingService extends TransactionBaseService {
*/
async setVariantPrices(
variants: ProductVariant[],
context: PriceSelectionContext
context: PriceSelectionContext = {}
): Promise<PricedVariant[]> {
const pricingContext = await this.collectPricingContext(context)
return await Promise.all(