diff --git a/.changeset/angry-trains-march.md b/.changeset/angry-trains-march.md new file mode 100644 index 0000000000..9a8c75d98a --- /dev/null +++ b/.changeset/angry-trains-march.md @@ -0,0 +1,5 @@ +--- +"@medusajs/admin-ui": patch +--- + +add location support in fulfillment modal diff --git a/.changeset/red-singers-swim.md b/.changeset/red-singers-swim.md new file mode 100644 index 0000000000..239343ecd4 --- /dev/null +++ b/.changeset/red-singers-swim.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): add get-variant endpoint diff --git a/.changeset/violet-lobsters-retire.md b/.changeset/violet-lobsters-retire.md new file mode 100644 index 0000000000..921c59efdc --- /dev/null +++ b/.changeset/violet-lobsters-retire.md @@ -0,0 +1,5 @@ +--- +"@medusajs/admin-ui": patch +--- + +Add order allocation to admin ui diff --git a/packages/admin-ui/ui/src/components/fundamentals/icons/circle-quarter-solid/index.tsx b/packages/admin-ui/ui/src/components/fundamentals/icons/circle-quarter-solid/index.tsx new file mode 100644 index 0000000000..5bbcc6efec --- /dev/null +++ b/packages/admin-ui/ui/src/components/fundamentals/icons/circle-quarter-solid/index.tsx @@ -0,0 +1,24 @@ +import IconProps from "../types/icon-type" +import React from "react" + +const CircleQuarterSolid: React.FC = ({ + size = "24", + color = "currentColor", + ...attributes +}) => { + return ( + + + + + ) +} + +export default CircleQuarterSolid diff --git a/packages/admin-ui/ui/src/components/fundamentals/icons/warning-circle.tsx b/packages/admin-ui/ui/src/components/fundamentals/icons/warning-circle.tsx index d97d275068..ef17106369 100644 --- a/packages/admin-ui/ui/src/components/fundamentals/icons/warning-circle.tsx +++ b/packages/admin-ui/ui/src/components/fundamentals/icons/warning-circle.tsx @@ -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 = ({ + fillType = "outline", + ...attributes +}) => { + if (fillType === "outline") { + return + } else { + return + } +} + +const ExclamationCircle: FC = ({ + size = "24", + color = "currentColor", + ...attributes +}) => { + return ( + + + + ) +} + const WarningCircle: FC = (props) => { const { fill, size, ...attributes } = props const line = fill || "#111827" @@ -39,4 +78,4 @@ const WarningCircle: FC = (props) => { ) } -export default WarningCircle +export default WarningCircleIcon diff --git a/packages/admin-ui/ui/src/components/molecules/input/index.tsx b/packages/admin-ui/ui/src/components/molecules/input/index.tsx index 396dd17fb8..beb09ba44e 100644 --- a/packages/admin-ui/ui/src/components/molecules/input/index.tsx +++ b/packages/admin-ui/ui/src/components/molecules/input/index.tsx @@ -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, "prefix"> & InputHeaderProps & { @@ -21,9 +22,11 @@ export type InputProps = Omit, "prefix"> & onFocus?: FocusEventHandler errors?: { [x: string]: unknown } prefix?: React.ReactNode + suffix?: React.ReactNode props?: React.HTMLAttributes } +// eslint-disable-next-line react/display-name const InputField = React.forwardRef( ( { @@ -39,6 +42,7 @@ const InputField = React.forwardRef( tooltipContent, tooltip, prefix, + suffix, errors, props, className, @@ -89,9 +93,9 @@ const InputField = React.forwardRef( )}
( ) : null} ( required={required} {...fieldProps} /> + {suffix ? ( + {suffix} + ) : null} {deletable && ( + )}
- {children} ) } @@ -146,9 +152,9 @@ Modal.Footer = ({ children, className }) => {
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 )} diff --git a/packages/admin-ui/ui/src/components/molecules/modal/layered-modal.tsx b/packages/admin-ui/ui/src/components/molecules/modal/layered-modal.tsx index bdb84c0ce9..05d3ad80c5 100644 --- a/packages/admin-ui/ui/src/components/molecules/modal/layered-modal.tsx +++ b/packages/admin-ui/ui/src/components/molecules/modal/layered-modal.tsx @@ -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 = ({
-

{screen.title}

+

{screen.title}

{screen.subtitle && ( ({screen.subtitle}) diff --git a/packages/admin-ui/ui/src/components/organisms/upload-modal/index.tsx b/packages/admin-ui/ui/src/components/organisms/upload-modal/index.tsx index 89285aaea3..f196007a22 100644 --- a/packages/admin-ui/ui/src/components/organisms/upload-modal/index.tsx +++ b/packages/admin-ui/ui/src/components/organisms/upload-modal/index.tsx @@ -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) {
-
+
-
-
{name}
-
+
+
{name}
+
{status || formattedSize}
@@ -68,18 +68,18 @@ function UploadSummary(props: UploadSummaryProps) { const { creations, updates, rejections, type } = props return (
-
+
{creations}  new {type}
{updates && ( -
- +
+ {updates}  updates
)} {rejections && ( -
+
{rejections}  rejections
@@ -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) {
- + Import {fileTitle}
-
+
Import {fileTitle}
@@ -241,14 +241,14 @@ function UploadModal(props: UploadModalProps) { // TODO: change this to actual progress once this we can track upload progress={100} action={ - + } /> )} -
+
{description2Title}
@@ -259,7 +259,7 @@ function UploadModal(props: UploadModalProps) { size={2967} action={ @@ -271,11 +271,11 @@ function UploadModal(props: UploadModalProps) {
-
+
+ +
+ + + ) +} +export default InventoryTable diff --git a/packages/admin-ui/ui/src/components/templates/inventory-table/use-inventory-column.tsx b/packages/admin-ui/ui/src/components/templates/inventory-table/use-inventory-column.tsx new file mode 100644 index 0000000000..a40ef5a4bf --- /dev/null +++ b/packages/admin-ui/ui/src/components/templates/inventory-table/use-inventory-column.tsx @@ -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 ( +
+
+ {original.variants[0]?.product?.thumbnail ? ( + + ) : ( + + )} +
+ {original.variants[0]?.product?.title || ""} +
+ ) + }, + }, + { + Header: "Variant", + Cell: ({ row: { original } }) => { + return
{original?.variants[0]?.title || "-"}
+ }, + }, + { + Header: "SKU", + accessor: "sku", + Cell: ({ cell: { value } }) => value, + }, + { + Header: "Incoming", + accessor: "incoming_quantity", + Cell: ({ row: { original } }) => ( +
+ {original.location_levels.reduce( + (acc, next) => acc + next.incoming_quantity, + 0 + )} +
+ ), + }, + { + Header: "In stock", + accessor: "stocked_quantity", + Cell: ({ row: { original } }) => ( +
+ {original.location_levels.reduce( + (acc, next) => acc + next.stocked_quantity, + 0 + )} +
+ ), + }, + ], + [] + ) + + return [columns] as const +} + +export default useInventoryTableColumn diff --git a/packages/admin-ui/ui/src/components/templates/inventory-table/use-inventory-filters.ts b/packages/admin-ui/ui/src/components/templates/inventory-table/use-inventory-filters.ts new file mode 100644 index 0000000000..db3bb53465 --- /dev/null +++ b/packages/admin-ui/ui/src/components/templates/inventory-table/use-inventory-filters.ts @@ -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, bs: Set) => { + 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 +} diff --git a/packages/admin-ui/ui/src/components/templates/product-table/use-product-column.tsx b/packages/admin-ui/ui/src/components/templates/product-table/use-product-column.tsx index 9e6db4bc67..dae6e0c82d 100644 --- a/packages/admin-ui/ui/src/components/templates/product-table/use-product-column.tsx +++ b/packages/admin-ui/ui/src/components/templates/product-table/use-product-column.tsx @@ -46,7 +46,7 @@ const useProductTableColumn = ({ setTileView, setListView, showList }) => { {original.thumbnail ? ( ) : ( diff --git a/packages/admin-ui/ui/src/domain/inventory/filter-dropdown.tsx b/packages/admin-ui/ui/src/domain/inventory/filter-dropdown.tsx new file mode 100644 index 0000000000..04f60edbfa --- /dev/null +++ b/packages/admin-ui/ui/src/domain/inventory/filter-dropdown.tsx @@ -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 ( +
+ +
+ Filters +
+ + {numberOfFilters ? numberOfFilters : "0"} + +
+
+
+ +
+ + } + >
+ {tabs && + tabs.map((t) => ( + handleTabClick(t.value)} + label={t.label} + isActive={activeTab === t.value} + removable={!!t.removable} + onRemove={() => handleRemoveTab(t.value)} + /> + ))} +
+ ) +} + +export default ProductsFilter diff --git a/packages/admin-ui/ui/src/domain/inventory/inventory/index.tsx b/packages/admin-ui/ui/src/domain/inventory/inventory/index.tsx index 1f70451790..0cba02289a 100644 --- a/packages/admin-ui/ui/src/domain/inventory/inventory/index.tsx +++ b/packages/admin-ui/ui/src/domain/inventory/inventory/index.tsx @@ -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={} className="h-fit" > -

Inventory

+
diff --git a/packages/admin-ui/ui/src/domain/inventory/locations/edit/index.tsx b/packages/admin-ui/ui/src/domain/inventory/locations/edit/index.tsx index fc4f4f2316..15b49e6a25 100644 --- a/packages/admin-ui/ui/src/domain/inventory/locations/edit/index.tsx +++ b/packages/admin-ui/ui/src/domain/inventory/locations/edit/index.tsx @@ -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, } } diff --git a/packages/admin-ui/ui/src/domain/orders/details/allocations/allocate-items-modal.tsx b/packages/admin-ui/ui/src/domain/orders/details/allocations/allocate-items-modal.tsx new file mode 100644 index 0000000000..c0e285a33f --- /dev/null +++ b/packages/admin-ui/ui/src/domain/orders/details/allocations/allocate-items-modal.tsx @@ -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 + close: () => void +} + +const AllocateItemsModal: React.FC = ({ + order, + close, + reservationItemsMap, +}) => { + const { mutateAsync: createReservation } = useAdminCreateReservation() + const { client: medusaClient } = useMedusa() + const notification = useNotification() + + const form = useForm({ + 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 ( +
+ + +
+ +
+ + +
+
+
+ + {isLoading || !stock_locations ? ( +
Loading...
+ ) : ( +
+

Allocate order items

+
+
+

Location

+

+ Choose where you wish to allocate from +

+
+
+ ( + + )} + /> +
+
+

Items to Allocate

+ + Select the number of items that you wish to allocate. + + +
+ +
+
+
+ + +
+
+ + + ) +} + +export default EditAllocationDrawer diff --git a/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/index.tsx b/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/index.tsx index d784a2db30..4d4bf5755f 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/index.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/index.tsx @@ -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 = ({ orderToFulfill, orderId, }) => { - const [toFulfill, setToFulfill] = useState([]) - const [quantities, setQuantities] = useState({}) + const { isFeatureEnabled } = useFeatureFlag() + const isLocationFulfillmentEnabled = + isFeatureEnabled("inventoryService") && + isFeatureEnabled("stockLocationService") + const [quantities, setQuantities] = useState>( + "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([ { 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 = ({ 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 = ({ 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 = ({ } return ( - - - - Create Fulfillment - - -
- Items - + + +
+ +
+ + +
+
+
+ +
+

Create Fulfillment

+
+ +
+
+

Locations

+ + Choose where you wish to fulfill from. + +
+ - - Send notifications - - -
-
- - +

+ When toggled, notification emails will be sent. +

- - - +
+
+
) } diff --git a/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/item-table.tsx b/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/item-table.tsx index c413d8611a..e01a956945 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/item-table.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/create-fulfillment/item-table.tsx @@ -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 + setQuantities: (quantities: Record) => 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 ( - - - Details - - Quantity - - - {items - ?.filter((i) => getFulfillableQuantity(i) > 0) - .map((item) => { - const checked = toFulfill.includes(item.id) - return ( - <> - - -
-
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" - }`} - > - - {checked && } - -
- - handleFulfillmentItemToggle(item)} - type="checkbox" - /> -
-
- -
-
- -
-
- - {item.title} - - {item?.variant?.title || ""} -
-
-
- - {toFulfill.includes(item.id) ? ( -
- 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, - } - )} - > - - - {quantities[item.id] || ""} - 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], - } - )} - > - - -
- ) : ( - - {getFulfillableQuantity(item)} - - )} -
-
- - ) - })} -
-
+
+ {items.map((item, idx) => { + return ( + + ) + })} +
) } +const FulfillmentLine = ({ + item, + locationId, + quantities, + handleQuantityUpdate, + setErrors, +}: { + locationId: string + item: LineItem + quantities: Record + handleQuantityUpdate: (value: number, id: string) => void + setErrors: (errors: Record) => 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 ( +
+
+
+ {item.thumbnail ? ( + + ) : ( + + )} +
+
+ + {item.title} + + {item?.variant && ( + + {`${item.variant.title}${ + item.variant.sku ? ` (${item.variant.sku})` : "" + }`} + + )} +
+
+
+ +
+

{availableQuantity || "N/A"} available

+

({inStockQuantity || "N/A"} in stock)

+
+
+ + {"/"} + {getFulfillableQuantity(item)} + + } + value={quantities[item.id]} + max={getFulfillableQuantity(item)} + onChange={(e) => + handleQuantityUpdate(e.target.valueAsNumber, item.id) + } + errors={ + validQuantity ? undefined : { quantity: "Quantity is not valid" } + } + /> +
+
+ ) +} export default CreateFulfillmentItemsTable diff --git a/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx b/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx new file mode 100644 index 0000000000..5906ca11d5 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/orders/details/detail-cards/summary.tsx @@ -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 = ({ + 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, 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 ( + + ) + } + actionables={actionables} + > +
+ {order.items?.map((item, i) => ( + + ))} + + {order?.discounts?.map((discount, index) => ( + + Discount:{" "} + + {discount.code} + +
+ } + /> + ))} + {order?.gift_cards?.map((giftCard, index) => ( + + Gift card: + + {giftCard.code} + +
+ +
+
+ } + /> + ))} + + + + +
+ {allocationModalIsOpen && ( + + )} + + ) +} + +export default SummaryCard diff --git a/packages/admin-ui/ui/src/domain/orders/details/index.tsx b/packages/admin-ui/ui/src/domain/orders/details/index.tsx index 0c02c21b81..d7f1287755 100644 --- a/packages/admin-ui/ui/src/domain/orders/details/index.tsx +++ b/packages/admin-ui/ui/src/domain/orders/details/index.tsx @@ -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 { 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 = () => {
- - {({ showModal }) => ( - -
- {order.items?.map((item, i) => ( - - ))} - - {order?.discounts?.map((discount, index) => ( - - Discount:{" "} - - {discount.code} - -
- } - /> - ))} - {order?.gift_cards?.map((giftCard, index) => ( - - Gift card: - - {giftCard.code} - -
- -
-
- } - /> - ))} - - - - -
- - )} - + + { +const OrderLine = ({ item, currencyCode, reservations }: OrderLineProps) => { + const { isFeatureEnabled } = useFeatureFlag() return ( -
-
-
+
+
+
{item.thumbnail ? ( ) : ( )}
-
+
{item.title} @@ -32,8 +44,8 @@ const OrderLine = ({ item, currencyCode }: OrderLineProps) => { )}
-
-
+
+
{formatAmountWithSymbol({ amount: (item?.total ?? 0) / item.quantity, @@ -45,6 +57,9 @@ const OrderLine = ({ item, currencyCode }: OrderLineProps) => {
x {item.quantity}
+ {isFeatureEnabled("inventoryService") && ( + + )}
{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(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 ( +
+ +
+ {!!awaitingAllocation && ( + + {awaitingAllocation} items await allocation + + )} + {reservations?.map((reservation) => ( + setReservation(reservation)} + /> + ))} +
+
+ } + side="bottom" + > + {awaitingAllocation ? ( + reservationsSum ? ( + + ) : ( + + ) + ) : ( + + )} + + + {reservation && ( + setReservation(null)} + reservation={reservation} + item={lineItem} + /> + )} +
+ ) +} + +const EditAllocationButton = ({ + reservation, + locationName, + onClick, +}: { + reservation: ReservationItemDTO + totalReservedQuantity: number + locationName?: string + lineItem: LineItem + onClick: () => void +}) => { + return ( +
+ {`${reservation.quantity} item: ${locationName}`} + +
+ ) +} + export default OrderLine diff --git a/packages/admin-ui/ui/src/domain/orders/index.tsx b/packages/admin-ui/ui/src/domain/orders/index.tsx index df7ddf5fc8..ada926638d 100644 --- a/packages/admin-ui/ui/src/domain/orders/index.tsx +++ b/packages/admin-ui/ui/src/domain/orders/index.tsx @@ -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"] diff --git a/packages/admin-ui/ui/src/domain/products/components/variant-form/edit-flow-variant-form/index.tsx b/packages/admin-ui/ui/src/domain/products/components/variant-form/edit-flow-variant-form/index.tsx index aaf026ce28..ccf65572e0 100644 --- a/packages/admin-ui/ui/src/domain/products/components/variant-form/edit-flow-variant-form/index.tsx +++ b/packages/admin-ui/ui/src/domain/products/components/variant-form/edit-flow-variant-form/index.tsx @@ -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 + 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 (
-
+

Options

-
+
{fields.map((field, index) => { return ( { - - - + {showStockAndInventory && ( + + + + )}

Shipping information can be required depending on your shipping diff --git a/packages/admin-ui/ui/src/domain/products/components/variant-form/variant-stock-form/index.tsx b/packages/admin-ui/ui/src/domain/products/components/variant-form/variant-stock-form/index.tsx index 4a5644d252..9111c9974d 100644 --- a/packages/admin-ui/ui/src/domain/products/components/variant-form/variant-stock-form/index.tsx +++ b/packages/admin-ui/ui/src/domain/products/components/variant-form/variant-stock-form/index.tsx @@ -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 (

Configure the inventory and stock for this variant.

-
-
-
-

Manage inventory

- { - return - }} - /> -
-

- When checked Medusa will regulate the inventory when orders and - returns are made. -

-
-
-
-

Allow backorders

- { - return - }} - /> -
-

- When checked the product will be available for purchase despite the - product being sold out -

-
-
+
+
{ {...register(path("barcode"))} />
+
+
+

Manage inventory

+ { + return + }} + /> +
+

+ When checked Medusa will regulate the inventory when orders and + returns are made. +

+
+
+
+

Allow backorders

+ { + return + }} + /> +
+

+ When checked the product will be available for purchase despite the + product being sold out +

+
+ +
+
+
+

Quantity

+
+

Location

+

In Stock

+
+
+
+ {selectedLocations.map((sl, i) => ( +
+
+ + + + {stockLocationsMap.get(sl.location_id)?.name} +
+
+ +
+
+ ))} +
+ + {!!selectedLocations.length && ( +
+

Total inventory at all locations

+

{`${totalStockedQuantity} available`}

+
+ )} + +
+
+
) diff --git a/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/edit-flow-variant-form/index.tsx b/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/edit-flow-variant-form/index.tsx new file mode 100644 index 0000000000..a7e4fcdfbd --- /dev/null +++ b/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/edit-flow-variant-form/index.tsx @@ -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 + 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() + * const { handleSubmit } = form + * + * const onSubmit = handleSubmit((data) => { + * // do something with data + * }) + * + * return ( + *
+ * + * + * + * ) + * } + */ +const EditFlowVariantForm = ({ + form, + isLoading, + locationLevels, + refetchInventory, + itemId, +}: Props) => { + if (isLoading) { + return null + } + + return ( + <> + + + ) +} + +export default EditFlowVariantForm diff --git a/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/variant-stock-form/index.tsx b/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/variant-stock-form/index.tsx new file mode 100644 index 0000000000..9dd1dd7960 --- /dev/null +++ b/packages/admin-ui/ui/src/domain/products/components/variant-inventory-form/variant-stock-form/index.tsx @@ -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 +} + +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 ( +
+
+
+

General

+
+ + + + +
+
+
+
+

Manage inventory

+ { + return + }} + /> +
+

+ When checked Medusa will regulate the inventory when orders and + returns are made. +

+
+
+
+

Allow backorders

+ { + return + }} + /> +
+

+ When checked the product will be available for purchase despite the + product being sold out +

+
+
+

Quantity

+ {!isLoading && locations && ( +
+
+
Location
+
In Stock
+
+ {locationLevels.map((level, i) => { + const locationDetails = locations.find( + (l) => l.id === level.location_id + ) + + return ( +
+
+ + + + {locationDetails?.name} +
+
+
+ + {`${ + level.stocked_quantity - level.available_quantity + } reserved`} + + {`${level.available_quantity} available`} +
+ +
+
+ ) + })} +
+ )} +
+
+ +
+
+
+ ) +} + +export const ManageLocationsScreen = ( + pop: () => void, + levels: InventoryLevelDTO[], + locations: StockLocationDTO[], + onSubmit: (value: any) => Promise +) => { + return { + title: "Manage locations", + onBack: () => pop(), + view: ( + + ), + } +} + +type ManageLocationFormProps = { + existingLevels: InventoryLevelDTO[] + locationOptions: StockLocationDTO[] + onSubmit: (value: any) => Promise +} + +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(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 ( +
+
+ +
+
+
+

Select locations that stock the selected variant

+

{`(${selectedLocations.length} of ${locationOptions.length} selected)`}

+
+ +
+ {locationOptions.map((loc) => { + const existingLevel = selectedLocations.find((l) => l === loc.id) + + return ( +
+
+ + + +

{loc.name}

+
+ handleToggleLocation(loc.id)} + /> +
+ ) + })} +
+
+ +
+ + +
+
+
+
+ ) +} + +export default VariantStockForm diff --git a/packages/admin-ui/ui/src/domain/products/edit/hooks/use-edit-product-actions.tsx b/packages/admin-ui/ui/src/domain/products/edit/hooks/use-edit-product-actions.tsx index 55bc4774f8..44c88e2fbf 100644 --- a/packages/admin-ui/ui/src/domain/products/edit/hooks/use-edit-product-actions.tsx +++ b/packages/admin-ui/ui/src/domain/products/edit/hooks/use-edit-product-actions.tsx @@ -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") diff --git a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/add-variant-modal.tsx b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/add-variant-modal.tsx index 48b7b7e308..5cbed27904 100644 --- a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/add-variant-modal.tsx +++ b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/add-variant-modal.tsx @@ -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({ 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 ( - +

Add Variant

- + -
+
+ +
+ + + ) +} + +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 diff --git a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/edit-variant-modal.tsx b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/edit-variant-modal.tsx index adbb3e67ce..13ccce6d55 100644 --- a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/edit-variant-modal.tsx +++ b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/edit-variant-modal.tsx @@ -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({ + // @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 ( - +

Edit Variant {variant.title && ( - + {" "} ({variant.title}) @@ -65,10 +117,10 @@ const EditVariantModal = ({
- + -
+
@@ -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, })), diff --git a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/index.tsx b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/index.tsx index 535daeaa7e..24fd35cf7a 100644 --- a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/index.tsx +++ b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/index.tsx @@ -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 (
@@ -91,6 +108,7 @@ const VariantsSection = ({ product }: Props) => { deleteVariant: handleDeleteVariant, updateVariant: handleEditVariant, duplicateVariant: handleDuplicateVariant, + updateVariantInventory: handleEditVariantInventory, }} />
@@ -118,6 +136,13 @@ const VariantsSection = ({ product }: Props) => { onClose={() => setVariantToEdit(undefined)} /> )} + {variantInventoryToEdit && ( + setVariantInventoryToEdit(undefined)} + /> + )} ) } @@ -135,11 +160,11 @@ const ProductOptions = () => { {Array.from(Array(2)).map((_, i) => { return (
-
+
    {Array.from(Array(3)).map((_, j) => (
  • -
    +
    {j}
  • @@ -153,7 +178,7 @@ const ProductOptions = () => { } return ( -
    +
    {options.map((option) => { return (
    @@ -164,7 +189,7 @@ const ProductOptions = () => { .filter((v, index, self) => self.indexOf(v) === index) .map((v, i) => (
  • -
    +
    {v}
  • diff --git a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/table.tsx b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/table.tsx index b744a3eb93..f3c1cc73a2 100644 --- a/packages/admin-ui/ui/src/domain/products/edit/sections/variants/table.tsx +++ b/packages/admin-ui/ui/src/domain/products/edit/sections/variants/table.tsx @@ -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[]>( - () => [ +export const useVariantsTableColumns = (inventoryIsEnabled = false) => { + const columns = useMemo[]>(() => { + const quantityColumns = [] + if (!inventoryIsEnabled) { + quantityColumns.push({ + Header: () => { + return ( +
    + Inventory +
    + ) + }, + id: "inventory", + accessor: "inventory_quantity", + maxWidth: 56, + Cell: ({ cell }) => { + return ( +
    + {cell.value} +
    + ) + }, + }) + } + return [ { Header: "Title", id: "title", @@ -50,34 +76,17 @@ export const useVariantsTableColumns = () => { ) }, }, - { - Header: () => { - return ( -
    - Inventory -
    - ) - }, - id: "inventory", - accessor: "inventory_quantity", - maxWidth: 56, - Cell: ({ cell }) => { - return ( -
    - {cell.value} -
    - ) - }, - }, - ], - [] - ) + ...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: , + onClick: () => updateVariantInventory(variant), + }) + } + return [ + { + label: "Edit Variant", + icon: , + onClick: () => updateVariant(variant), + }, + ...inventoryManagementActions, + { + label: "Duplicate Variant", + onClick: () => + // @ts-ignore + duplicateVariant({ + ...variant, + title: variant.title + " Copy", + }), + icon: , + }, + { + label: "Delete Variant", + onClick: () => deleteVariant(variant.id), + icon: , + variant: "danger", + }, + ] + } return ( - {headerGroups?.map((headerGroup) => ( - - {headerGroup.headers.map((col) => ( - - {col.render("Header")} - - ))} - - ))} + {headerGroups?.map((headerGroup) => { + const { key, ...rest } = headerGroup.getHeaderGroupProps() + return ( + + {headerGroup.headers.map((col) => { + const { key, ...rest } = col.getHeaderProps() + + return ( + + {col.render("Header")} + + ) + })} + + ) + })} {rows.map((row) => { prepareRow(row) + const actionables = getTableRowActionables(row.original) + const { key, ...rest } = row.getRowProps() return ( - + {row.cells.map((cell) => { + const { key, ...rest } = cell.getCellProps() return ( - + {cell.render("Cell")} ) })}
    - , - onClick: () => updateVariant(row.original), - }, - { - label: "Duplicate Variant", - onClick: () => - // @ts-ignore - duplicateVariant({ - ...row.original, - title: row.original.title + " Copy", - }), - icon: , - }, - { - label: "Delete Variant", - onClick: () => deleteVariant(row.original.id), - icon: , - variant: "danger", - }, - ]} - /> +
    diff --git a/packages/admin-ui/ui/src/domain/products/new/add-variants/index.tsx b/packages/admin-ui/ui/src/domain/products/new/add-variants/index.tsx index 9d5611ef5e..9c3b3f1264 100644 --- a/packages/admin-ui/ui/src/domain/products/new/add-variants/index.tsx +++ b/packages/admin-ui/ui/src/domain/products/new/add-variants/index.tsx @@ -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 = ({
    {options.length > 0 && (
    -
    +
    Option title Variations (comma separated)
    @@ -276,7 +280,7 @@ const AddVariantsForm = ({ return (
    @@ -350,7 +354,7 @@ const AddVariantsForm = ({
    {variants?.length > 0 && (
    -
    +

    Variant

    Inventory

    @@ -380,7 +384,7 @@ const AddVariantsForm = ({
    - +

    Create Variant

    @@ -405,7 +413,7 @@ const AddVariantsForm = ({ /> -
    +
    @@ -227,14 +231,17 @@ const NewVariant = ({ />
    - - +

    Edit Variant {source.general.title && ( - + ({source.general.title}) )} @@ -248,7 +255,7 @@ const NewVariant = ({ /> -
    +