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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/admin-ui": patch
|
||||
---
|
||||
|
||||
add location support in fulfillment modal
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
---
|
||||
|
||||
feat(medusa): add get-variant endpoint
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
@@ -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} </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} </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} </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
|
||||
+71
@@ -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
|
||||
+466
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+160
-142
@@ -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"]
|
||||
|
||||
|
||||
+21
-14
@@ -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
|
||||
|
||||
+173
-37
@@ -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>
|
||||
)
|
||||
|
||||
+60
@@ -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
|
||||
+355
@@ -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")
|
||||
|
||||
+59
-9
@@ -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: {
|
||||
|
||||
+192
@@ -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
|
||||
+64
-12
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+12
-12
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user