feat(admin-ui,medusa): Reservations management (#4081)

* add location filtering to list-location levels

* cleanup

* add location filtering to list-location levels

* cleanup

* Initial work on route,table,new reservation form

* generated types

* add block

* udpate clients

* initial create reservation

* update actionables for reservation table

* update edit-allocation modal

* misc naming updates

* update reservations table

* add expand capabilities for list-reservations

* expand fields and show columns

* update oas

* make remove item work in focus modal

* add yarn lock

* add integration test

* Fix display when label doesn't match search term

* remove unused file

* Update packages/admin-ui/ui/src/components/templates/reservations-table/components/reservation-form/index.tsx

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* Update packages/admin-ui/ui/src/domain/orders/details/allocations/edit-allocation-modal.tsx

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* Update packages/admin-ui/ui/src/components/templates/reservations-table/new/index.tsx

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* initial changes

* add changeset

* update font size

* cleanup reservations table + select

* add decorated inventory item type

* use type

* feedback changes

* Update packages/admin-ui/ui/src/components/molecules/item-search/index.tsx

Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>

* decorate response for list inventory item to include total quantities

* update decorated properties

* decorate type

* adrien feedback

* Update packages/generated/client-types/src/lib/models/DecoratedInventoryItemDTO.ts

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>

* update join-utils

* fix caching

---------

Co-authored-by: Rares Capilnar <rares.capilnar@gmail.com>
Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
Co-authored-by: Riqwan Thamir <rmthamir@gmail.com>
This commit is contained in:
Philip Korsholm
2023-05-23 05:24:28 +02:00
committed by GitHub
parent 87444488b5
commit 4f3c8f5d70
35 changed files with 1717 additions and 135 deletions

View File

@@ -0,0 +1,9 @@
---
"@medusajs/client-types": patch
"medusa-react": patch
"@medusajs/medusa-js": patch
"@medusajs/admin-ui": patch
"@medusajs/medusa": patch
---
feat(medusa,client-types,medusa-js,admin-ui,medusa-react): add reservation table and creation

View File

@@ -533,6 +533,8 @@ describe("Inventory Items endpoints", () => {
available_quantity: 5,
}),
]),
reserved_quantity: 0,
stocked_quantity: 15,
})
)
})

View File

@@ -203,5 +203,27 @@ describe("Inventory Items endpoints", () => {
"The reservation quantity cannot be greater than the unfulfilled line item quantity",
})
})
it("lists reservations with inventory_items and line items", async () => {
const api = useApi()
const res = await api.get(
`/admin/reservations?expand=line_item,inventory_item`,
adminHeaders
)
expect(res.status).toEqual(200)
expect(res.data.reservations.length).toEqual(1)
expect(res.data.reservations).toEqual(
expect.arrayContaining([
expect.objectContaining({
inventory_item: expect.objectContaining({}),
line_item: expect.objectContaining({
order: expect.objectContaining({}),
}),
}),
])
)
})
})
})

View File

@@ -34,6 +34,7 @@
"@radix-ui/react-dialog": "^1.0.2",
"@radix-ui/react-dropdown-menu": "^2.0.2",
"@radix-ui/react-popover": "^1.0.3",
"@radix-ui/react-portal": "^1.0.2",
"@radix-ui/react-radio-group": "^1.1.1",
"@radix-ui/react-select": "^1.2.0",
"@radix-ui/react-switch": "^1.0.1",

View File

@@ -0,0 +1,110 @@
import {
AdminGetInventoryItemsParams,
DecoratedInventoryItemDTO,
} from "@medusajs/medusa"
import { ControlProps, OptionProps, SingleValue } from "react-select"
import Control from "../select/next-select/components/control"
import { NextSelect } from "../select/next-select"
import SearchIcon from "../../fundamentals/icons/search-icon"
import { useAdminInventoryItems } from "medusa-react"
import { useDebounce } from "../../../hooks/use-debounce"
import { useState } from "react"
type Props = {
onItemSelect: (item: itemType) => void
clearOnSelect?: boolean
filters?: AdminGetInventoryItemsParams
}
type ItemOption = {
label: string | undefined
value: string | undefined
inventoryItem: DecoratedInventoryItemDTO
}
const ItemSearch = ({ onItemSelect, clearOnSelect, filters = {} }: Props) => {
const [itemSearchTerm, setItemSearchTerm] = useState<string | undefined>()
const debouncedItemSearchTerm = useDebounce(itemSearchTerm, 500)
const queryEnabled = !!debouncedItemSearchTerm?.length
const { isLoading, inventory_items } = useAdminInventoryItems(
{
q: debouncedItemSearchTerm,
...filters,
},
{ enabled: queryEnabled }
)
const onChange = (item: SingleValue<ItemOption>) => {
if (item) {
onItemSelect(item.inventoryItem)
}
}
const options = inventory_items?.map(
(inventoryItem: DecoratedInventoryItemDTO) => ({
label:
inventoryItem.title ||
inventoryItem.variants?.[0]?.product?.title ||
inventoryItem.sku,
value: inventoryItem.id,
inventoryItem,
})
) as ItemOption[]
const filterOptions = () => true
return (
<div>
<NextSelect
isMulti={false}
components={{ Option: ProductOption, Control: SearchControl }}
onInputChange={setItemSearchTerm}
options={options}
placeholder="Choose an item"
isSearchable={true}
noOptionsMessage={() => "No items found"}
openMenuOnClick={!!inventory_items?.length}
onChange={onChange}
value={null}
isLoading={queryEnabled && isLoading}
filterOption={filterOptions} // TODO: Remove this when we can q for inventory item titles
/>
</div>
)
}
const ProductOption = ({ innerProps, data }: OptionProps<ItemOption>) => {
return (
<div
{...innerProps}
className="text-small grid w-full cursor-pointer grid-cols-2 place-content-between px-4 py-2 transition-all hover:bg-gray-50"
>
<div>
<p>{data.label}</p>
<p className="text-grey-50">{data.inventoryItem.sku}</p>
</div>
<div className="text-right">
<p className="text-grey-50">{`${data.inventoryItem.stocked_quantity} stock`}</p>
<p className="text-grey-50">{`${
data.inventoryItem.stocked_quantity -
data.inventoryItem.reserved_quantity
} available`}</p>
</div>
</div>
)
}
const SearchControl = ({ children, ...props }: ControlProps<ItemOption>) => (
<Control {...props}>
<span className="mr-4">
<SearchIcon size={16} className="text-grey-50" />
</span>
{children}
</Control>
)
export default ItemSearch

View File

@@ -0,0 +1,45 @@
import { useEffect, useMemo } from "react"
import { useAdminStockLocations } from "medusa-react"
import { NextSelect } from "../select/next-select"
const LocationDropdown = ({
selectedLocation,
onChange,
}: {
selectedLocation?: string
onChange: (id: string) => void
}) => {
const { stock_locations: locations, isLoading } = useAdminStockLocations()
useEffect(() => {
if (!selectedLocation && !isLoading && locations?.length) {
onChange(locations[0].id)
}
}, [isLoading, locations, onChange, selectedLocation])
const selectedLocObj = useMemo(() => {
if (!isLoading && locations) {
return locations.find((l) => l.id === selectedLocation) ?? locations[0]
}
}, [selectedLocation, locations, isLoading])
if (isLoading || !locations || !selectedLocObj) {
return null
}
return (
<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 }}
/>
)
}
export default LocationDropdown

View File

@@ -1,5 +1,6 @@
import { PropsWithChildren } from "react"
import { AnimatePresence, motion } from "framer-motion"
import * as Portal from "@radix-ui/react-portal"
const MODAL_WIDTH = 560
@@ -14,46 +15,48 @@ type SideModalProps = PropsWithChildren<{
function SideModal(props: SideModalProps) {
const { isVisible, children, close } = props
return (
<AnimatePresence>
{isVisible && (
<>
<motion.div
onClick={close}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease: "easeInOut" }}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 99,
background: "rgba(0,0,0,.3)",
}}
></motion.div>
<motion.div
transition={{ ease: "easeInOut" }}
initial={{ right: -MODAL_WIDTH }}
style={{
position: "fixed",
height: "100%",
width: MODAL_WIDTH,
background: "white",
right: 0,
top: 0,
zIndex: 200,
}}
className="overflow-hidden rounded border"
animate={{ right: 0 }}
exit={{ right: -MODAL_WIDTH }}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
<Portal.Root>
<AnimatePresence>
{isVisible && (
<>
<motion.div
onClick={close}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ease: "easeInOut" }}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 99,
background: "rgba(0,0,0,.3)",
}}
></motion.div>
<motion.div
transition={{ ease: "easeInOut" }}
initial={{ right: -MODAL_WIDTH }}
style={{
position: "fixed",
height: "100%",
width: MODAL_WIDTH,
background: "white",
right: 0,
top: 0,
zIndex: 200,
}}
className="overflow-hidden rounded border"
animate={{ right: 0 }}
exit={{ right: -MODAL_WIDTH }}
>
{children}
</motion.div>
</>
)}
</AnimatePresence>
</Portal.Root>
)
}

View File

@@ -1,15 +1,16 @@
import type { GroupBase, Props, SelectInstance } from "react-select"
import {
forwardRef,
MutableRefObject,
ReactElement,
RefAttributes,
forwardRef,
useContext,
useRef,
} from "react"
import type { GroupBase, Props, SelectInstance } from "react-select"
import ReactSelect from "react-select"
import { ModalContext } from "../../../modal"
import { AdjacentContainer } from "../components"
import { ModalContext } from "../../../modal"
import ReactSelect from "react-select"
import { useSelectProps } from "../use-select-props"
export type SelectComponent = <

View File

@@ -1,7 +1,7 @@
import isEqual from "lodash/isEqual"
import { useEffect, useState } from "react"
import { ActionMeta, GroupBase, OnChangeValue, Props } from "react-select"
import Components from "./components"
import BaseComponents from "./components"
import { formatOptionLabel, hasLabel } from "./utils"
export const useSelectProps = <
@@ -64,7 +64,7 @@ export const useSelectProps = <
return {
label,
components: Components,
components: { ...BaseComponents, ...components },
styles: {
menuPortal: (base) => ({ ...base, zIndex: 60 }),
...styles,

View File

@@ -0,0 +1,140 @@
import Button from "../../../../fundamentals/button"
import { Controller } from "react-hook-form"
import InputField from "../../../../molecules/input"
import ItemSearch from "../../../../molecules/item-search"
import LocationDropdown from "../../../../molecules/location-dropdown"
import { NestedForm } from "../../../../../utils/nested-form"
import { DecoratedInventoryItemDTO } from "@medusajs/medusa"
import React from "react"
export type GeneralFormType = {
location: string
items: Partial<DecoratedInventoryItemDTO>
description: string
quantity: number
}
type Props = {
form: NestedForm<GeneralFormType>
}
const ReservationForm: React.FC<Props> = ({ form }) => {
const { register, path, watch, control, setValue } = form
const selectedItem = watch(path("item"))
const selectedLocation = watch(path("location"))
const locationLevel = selectedItem?.location_levels?.find(
(l) => l.location_id === selectedLocation
)
return (
<div className="flex w-full flex-col gap-6">
<div className="grid w-full grid-cols-2 items-center">
<div>
<p className="inter-base-semibold mb-1">Location</p>
<p className="text-grey-50">Choose where you wish to reserve from.</p>
</div>
<Controller
control={control}
name={path("location")}
render={({ field: { onChange } }) => {
return (
<LocationDropdown
onChange={onChange}
selectedLocation={selectedLocation}
/>
)
}}
/>
</div>
<div className="grid w-full grid-cols-2 items-center">
<div>
<p className="inter-base-semibold mb-1">Item to reserve</p>
<p className="text-grey-50">
Select the item that you wish to reserve.
</p>
</div>
<Controller
control={control}
name={path("item")}
render={({ field: { onChange } }) => {
return (
<ItemSearch
onItemSelect={onChange}
clearOnSelect={true}
filters={{ location_id: selectedLocation }}
/>
)
}}
/>
{selectedItem && locationLevel && (
<div className="col-span-2 flex w-full flex-col">
<div
className={`
bg-grey-5 text-grey-50 border-grey-20
mt-8
grid border-collapse grid-cols-2 grid-rows-5
[&>*]:border-r [&>*]:border-b [&>*]:py-2
[&>*:nth-child(odd)]:border-l [&>*:nth-child(odd)]:pl-4
[&>*:nth-child(even)]:pr-4 [&>*:nth-child(even)]:text-right
[&>*:nth-child(-n+2)]:border-t`}
>
<div className="rounded-tl-rounded">Item</div>
<div className="rounded-tr-rounded">
{selectedItem!.title ?? "N/A"}
</div>
<div>SKU</div>
<div>{selectedItem.sku ?? "N/A"}</div>
<div>In stock</div>
<div>{locationLevel?.stocked_quantity}</div>
<div>Available</div>
<div>
{locationLevel?.stocked_quantity -
locationLevel?.reserved_quantity}
</div>
<div className="rounded-bl-rounded">Reserve</div>
<div className="bg-grey-0 rounded-br-rounded text-grey-80 flex items-center">
<input
className="remove-number-spinner inter-base-regular w-full shrink border-none bg-transparent text-right font-normal outline-none outline-0"
{...register(path("quantity"), {
valueAsNumber: true,
})}
type="number"
min={0}
max={
locationLevel?.stocked_quantity -
locationLevel?.reserved_quantity
}
/>
</div>
</div>
<div className="mt-4 flex w-full justify-end">
<Button
variant="ghost"
className="border"
size="small"
type="button"
onClick={() => setValue(path("item"), undefined)}
>
Remove item
</Button>
</div>
</div>
)}
</div>
<div className="border-grey border-grey-20 grid w-full grid-cols-2 items-center border-t py-6">
<div>
<p className="inter-base-semibold mb-1">Description</p>
<p className="text-grey-50">What type of reservation is this?</p>
</div>
<InputField
{...register(path("description"))}
placeholder="Description"
/>
</div>
</div>
)
}
export default ReservationForm

View File

@@ -0,0 +1,361 @@
import { Cell, Row, TableRowProps, usePagination, useTable } from "react-table"
import React, { useEffect, useMemo, useState } from "react"
import {
useAdminReservations,
useAdminStockLocations,
useAdminStore,
} from "medusa-react"
import Button from "../../fundamentals/button"
import DeletePrompt from "../../organisms/delete-prompt"
import EditAllocationDrawer from "../../../domain/orders/details/allocations/edit-allocation-modal"
import EditIcon from "../../fundamentals/icons/edit-icon"
import Fade from "../../atoms/fade-wrapper"
import NewReservation from "./new"
import { NextSelect } from "../../molecules/select/next-select"
import { ReservationItemDTO } from "@medusajs/types"
import Table from "../../molecules/table"
import TableContainer from "../../../components/organisms/table-container"
import TrashIcon from "../../fundamentals/icons/trash-icon"
import { isEmpty } from "lodash"
import qs from "qs"
import { useAdminDeleteReservation } from "medusa-react"
import { useLocation } from "react-router-dom"
import { useReservationFilters } from "./use-reservation-filters"
import useReservationsTableColumns from "./use-reservations-columns"
import useToggleState from "../../../hooks/use-toggle-state"
const DEFAULT_PAGE_SIZE = 15
type ReservationsTableProps = {}
const LocationDropdown = ({
selectedLocation,
onChange,
}: {
selectedLocation: string
onChange: (id: string) => void
}) => {
const { stock_locations: locations, isLoading } = useAdminStockLocations()
useEffect(() => {
if (!selectedLocation && !isLoading && locations?.length) {
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 ReservationsTable: React.FC<ReservationsTableProps> = () => {
const { store } = useAdminStore()
const {
state: createReservationState,
close: closeReservationCreate,
open: openReservationCreate,
} = useToggleState()
const location = useLocation()
const defaultQuery = useMemo(() => {
if (store) {
return {
location_id: store.default_location_id,
}
}
return {}
}, [store])
const {
reset,
paginate,
setLocationFilter,
setQuery: setFreeText,
queryObject,
representationObject,
} = useReservationFilters(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 { reservations, isLoading, count } = useAdminReservations(
{
...queryObject,
expand: "line_item,inventory_item",
},
{
enabled: !!store,
}
)
useEffect(() => {
const controlledPageCount = Math.ceil(count! / queryObject.limit)
setNumPages(controlledPageCount)
}, [reservations])
const updateUrlFromFilter = (obj = {}) => {
const stringified = qs.stringify(obj)
window.history.replaceState(`/a/reservations`, "", `${`?${stringified}`}`)
}
const refreshWithFilters = () => {
const filterObj = representationObject
if (isEmpty(filterObj)) {
updateUrlFromFilter({ offset: 0, limit: DEFAULT_PAGE_SIZE })
} else {
updateUrlFromFilter(filterObj)
}
}
useEffect(() => {
refreshWithFilters()
}, [representationObject])
const [columns] = useReservationsTableColumns()
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
gotoPage,
canPreviousPage,
canNextPage,
pageCount,
nextPage,
previousPage,
// Get the state from the instance
state: { pageIndex },
} = useTable(
{
columns,
data: reservations || [],
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: "Reservations",
currentPage: pageIndex + 1,
pageCount: pageCount,
nextPage: handleNext,
prevPage: handlePrev,
hasNext: canNextPage,
hasPrev: canPreviousPage,
}}
numberOfRows={limit}
isLoading={isLoading}
>
<Table
enableSearch
searchClassName="h-[40px]"
handleSearch={setQuery}
searchValue={query}
tableActions={
<div className="flex gap-2">
<LocationDropdown
selectedLocation={
queryObject.location_id || store?.default_location_id
}
onChange={(id) => {
setLocationFilter(id)
gotoPage(0)
}}
/>
<Button
variant="secondary"
size="small"
onClick={openReservationCreate}
>
Create reservation
</Button>
</div>
}
{...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 <ReservationRow row={row} key={key} {...rest} />
})}
</Table.Body>
</Table>
</TableContainer>
<Fade isVisible={createReservationState} isFullScreen={true}>
<NewReservation
locationId={queryObject.location_id}
onClose={closeReservationCreate}
/>
</Fade>
</>
)
}
const ReservationRow = ({
row,
...rest
}: {
row: Row<ReservationItemDTO>
} & TableRowProps) => {
const inventory = row.original
const { mutate: deleteReservation } = useAdminDeleteReservation(inventory.id)
const [showEditReservation, setShowEditReservation] =
useState<ReservationItemDTO | null>(null)
const [showDeleteReservation, setShowDeleteReservation] = useState(false)
const getRowActionables = () => {
const actions = [
{
label: "Edit",
onClick: () => setShowEditReservation(row.original),
icon: <EditIcon size={20} />,
},
{
label: "Delete",
variant: "danger",
icon: <TrashIcon size={20} />,
onClick: () => setShowDeleteReservation(true),
},
]
return actions
}
return (
<>
<Table.Row
color={"inherit"}
forceDropdown
actions={getRowActionables()}
{...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>
)
})}
</Table.Row>
{showEditReservation && (
<EditAllocationDrawer
close={() => setShowEditReservation(null)}
reservation={row.original}
/>
)}
{showDeleteReservation && (
<DeletePrompt
text={"Are you sure you want to remove this reservation?"}
heading={"Remove reservation"}
successText={"Reservation has been removed"}
onDelete={async () => await deleteReservation(undefined)}
handleClose={() => setShowDeleteReservation(false)}
/>
)}
</>
)
}
export default ReservationsTable

View File

@@ -0,0 +1,122 @@
import MetadataForm, {
MetadataFormType,
getSubmittableMetadata,
} from "../../../forms/general/metadata-form"
import ReservationForm, {
GeneralFormType,
} from "../components/reservation-form"
import { AdminPostReservationsReq } from "@medusajs/medusa"
import Button from "../../../fundamentals/button"
import CrossIcon from "../../../fundamentals/icons/cross-icon"
import FocusModal from "../../../molecules/modal/focus-modal"
import { getErrorMessage } from "../../../../utils/error-messages"
import { nestedForm } from "../../../../utils/nested-form"
import { useAdminCreateReservation } from "medusa-react"
import { useForm } from "react-hook-form"
import useNotification from "../../../../hooks/use-notification"
type NewReservationFormType = {
general: GeneralFormType
metadata: MetadataFormType
}
const NewReservation = ({
onClose,
locationId,
}: {
onClose: () => void
locationId?: string
}) => {
const { mutateAsync: createReservation } = useAdminCreateReservation()
const form = useForm<NewReservationFormType>({
defaultValues: {
general: {
location: locationId,
item: undefined,
description: undefined,
quantity: 0,
},
},
reValidateMode: "onBlur",
mode: "onBlur",
})
const { handleSubmit } = form
const notification = useNotification()
const onSubmit = async (data) => {
const payload = await createPayload(data)
createReservation(payload, {
onSuccess: () => {
notification("Success", "Successfully created reservation", "success")
onClose()
},
onError: (err) => {
notification("Error", getErrorMessage(err), "error")
},
})
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
<FocusModal>
<FocusModal.Header>
<div className="medium:w-8/12 flex w-full justify-between px-8">
<Button
size="small"
variant="ghost"
type="button"
onClick={onClose}
>
<CrossIcon size={20} />
</Button>
<div className="gap-x-small flex">
<Button
size="small"
variant="secondary"
type="button"
onClick={onClose}
>
Cancel
</Button>
<Button size="small" variant="primary" type="submit">
Save reservation
</Button>
</div>
</div>
</FocusModal.Header>
<FocusModal.Main className="no-scrollbar flex w-full justify-center">
<div className="medium:w-7/12 large:w-6/12 small:w-4/5 my-16 max-w-[700px]">
<h1 className="mb-base text-grey-90 text-xlarge font-semibold">
Reserve Item
</h1>
<div className="mt-xlarge gap-y-xlarge flex w-full pb-0.5">
<ReservationForm form={nestedForm(form, "general")} />
</div>
<div className="border-grey border-grey-20 w-full items-center border-t pt-6">
<p className="inter-base-semibold mb-2">Metadata</p>
<MetadataForm form={nestedForm(form, "metadata")} />
</div>
</div>
</FocusModal.Main>
</FocusModal>
</form>
)
}
const createPayload = (
data: NewReservationFormType
): AdminPostReservationsReq => {
return {
location_id: data.general.location,
inventory_item_id: data.general.item.id!,
quantity: data.general.quantity,
description: data.general.description,
metadata: getSubmittableMetadata(data.metadata),
}
}
export default NewReservation

View File

@@ -0,0 +1,470 @@
import { useMemo, useReducer, useState } from "react"
import { omit } from "lodash"
import qs from "qs"
import { relativeDateFormatToTimestamp } from "../../../utils/time"
type ReservationDateFilter = null | {
gt?: string
lt?: string
}
type reservationFilterAction =
| { type: "setQuery"; payload: string | null }
| { type: "setFilters"; payload: ReservationFilterState }
| { type: "reset"; payload: ReservationFilterState }
| { type: "setOffset"; payload: number }
| { type: "setDefaults"; payload: ReservationDefaultFilters | null }
| { type: "setLocation"; payload: string }
| { type: "setLimit"; payload: number }
interface ReservationFilterState {
query?: string | null
limit: number
offset: number
location: string
additionalFilters: ReservationDefaultFilters | null
}
const allowedFilters = ["q", "offset", "limit", "location_id"]
const DefaultTabs = {}
const formatDateFilter = (filter: ReservationDateFilter) => {
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: ReservationFilterState,
action: reservationFilterAction
): ReservationFilterState => {
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 ReservationDefaultFilters = {
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 useReservationFilters = (
existing?: string,
defaultFilters: ReservationDefaultFilters | 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("reservation::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: ReservationDefaultFilters | null) => {
dispatch({ type: "setDefaults", payload: filters })
}
const setLimit = (limit: number) => {
dispatch({ type: "setLimit", payload: limit })
}
const setLocationFilter = (loc: string) => {
dispatch({ type: "setLocation", payload: loc })
dispatch({ type: "setOffset", payload: 0 })
}
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: ReservationFilterState) => {
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 ReservationDateFilter
)
} else {
toQuery[stateFilterMap[key]] = value.filter
}
} else if (key === "location") {
toQuery[stateFilterMap[key]] = value
}
}
toQuery["expand"] = "line_item,inventory_item"
return toQuery
}
const getQueryString = () => {
const obj = getQueryObject()
return qs.stringify(obj, { skipNulls: true })
}
const getRepresentationObject = (fromObject?: ReservationFilterState) => {
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" || key === "location") {
toQuery[stateFilterMap[key] || 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: ReservationFilterState) => {
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: ReservationDefaultFilters | null = null
): ReservationFilterState => {
const defaultVal: ReservationFilterState = {
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
}

View File

@@ -0,0 +1,41 @@
import moment from "moment"
import { useMemo } from "react"
const useReservationsTableColumns = () => {
const columns = useMemo(
() => [
{
Header: "SKU",
accessor: "inventory_item.sku",
Cell: ({ cell: { value } }) => value,
},
{
Header: "Order ID",
accessor: "line_item.order.display_id",
Cell: ({ cell: { value } }) => value ?? "-",
},
{
Header: "Description",
accessor: "description",
Cell: ({ cell: { value } }) => value,
},
{
Header: "Created",
accessor: "created_at",
Cell: ({ cell: { value } }) => moment(value).format("MMM Do YYYY"),
},
{
Header: () => <div className="pr-2 text-right">Quantity</div>,
accessor: "quantity",
Cell: ({ cell: { value } }) => (
<div className="w-full pr-2 text-right">{value}</div>
),
},
],
[]
)
return [columns] as const
}
export default useReservationsTableColumns

View File

@@ -2,7 +2,7 @@ import TableViewHeader from "../../components/organisms/custom-table-header"
import { useNavigate } from "react-router-dom"
type P = {
activeView: "inventory" | "locations"
activeView: "inventory" | "locations" | "reservations"
}
/*
@@ -17,10 +17,10 @@ function InventoryPageTableHeader(props: P) {
if (v === "inventory") {
navigate(`/a/inventory`)
} else {
navigate(`/a/inventory/locations`)
navigate(`/a/inventory/${v}`)
}
}}
views={["inventory", "locations"]}
views={["inventory", "locations", "reservations"]}
activeView={props.activeView}
/>
)

View File

@@ -1,12 +1,14 @@
import { Route, Routes } from "react-router-dom"
import InventoryView from "./inventory"
import Locations from "./locations"
import Reservations from "./reservations"
const Inventory = () => {
return (
<Routes>
<Route index element={<InventoryView />} />
<Route path="/locations/*" element={<Locations />} />
<Route path="/reservations/*" element={<Reservations />} />
</Routes>
)
}

View File

@@ -0,0 +1,22 @@
import Spacer from "../../../components/atoms/spacer"
import BodyCard from "../../../components/organisms/body-card"
import ReservationsTable from "../../../components/templates/reservations-table"
import InventoryPageTableHeader from "../header"
const Reservations = () => {
return (
<div className="flex h-full grow flex-col">
<div className="flex w-full grow flex-col">
<BodyCard
customHeader={<InventoryPageTableHeader activeView="reservations" />}
className="h-fit"
>
<ReservationsTable />
</BodyCard>
<Spacer />
</div>
</div>
)
}
export default Reservations

View File

@@ -3,17 +3,10 @@
/* eslint-disable */
import { SetRelation, Merge } from "../core/ModelUtils"
import type { InventoryItemDTO } from "./InventoryItemDTO"
import type { InventoryLevelDTO } from "./InventoryLevelDTO"
import type { ProductVariant } from "./ProductVariant"
import type { DecoratedInventoryItemDTO } from "./DecoratedInventoryItemDTO"
export interface AdminInventoryItemsListWithVariantsAndLocationLevelsRes {
inventory_items: Array<
InventoryItemDTO & {
location_levels?: Array<InventoryLevelDTO>
variants?: Array<ProductVariant>
}
>
inventory_items: Array<DecoratedInventoryItemDTO>
/**
* The total number of items available
*/

View File

@@ -3,10 +3,10 @@
/* eslint-disable */
import { SetRelation, Merge } from "../core/ModelUtils"
import type { ReservationItemDTO } from "./ReservationItemDTO"
import type { ExtendedReservationItem } from "./ExtendedReservationItem"
export interface AdminReservationsListRes {
reservations: Array<ReservationItemDTO>
reservations: Array<ExtendedReservationItem>
/**
* The total number of items available
*/

View File

@@ -0,0 +1,21 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import { SetRelation, Merge } from "../core/ModelUtils"
import type { InventoryItemDTO } from "./InventoryItemDTO"
import type { InventoryLevelDTO } from "./InventoryLevelDTO"
import type { ProductVariant } from "./ProductVariant"
export type DecoratedInventoryItemDTO = InventoryItemDTO & {
location_levels?: Array<InventoryLevelDTO>
variants?: Array<ProductVariant>
/**
* The total quantity of the item in stock across levels
*/
stocked_quantity: number
/**
* The total quantity of the item available across levels
*/
reserved_quantity: number
}

View File

@@ -0,0 +1,19 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import { SetRelation, Merge } from "../core/ModelUtils"
import type { InventoryItemDTO } from "./InventoryItemDTO"
import type { LineItem } from "./LineItem"
import type { ReservationItemDTO } from "./ReservationItemDTO"
export type ExtendedReservationItem = ReservationItemDTO & {
/**
* optional line item
*/
line_item?: LineItem
/**
* inventory item from inventory module
*/
inventory_item?: InventoryItemDTO
}

View File

@@ -310,6 +310,7 @@ export type { Currency } from "./Currency"
export type { Customer } from "./Customer"
export type { CustomerGroup } from "./CustomerGroup"
export type { CustomShippingOption } from "./CustomShippingOption"
export type { DecoratedInventoryItemDTO } from "./DecoratedInventoryItemDTO"
export type { Discount } from "./Discount"
export type { DiscountCondition } from "./DiscountCondition"
export type { DiscountConditionCustomerGroup } from "./DiscountConditionCustomerGroup"
@@ -320,6 +321,7 @@ export type { DiscountConditionProductType } from "./DiscountConditionProductTyp
export type { DiscountRule } from "./DiscountRule"
export type { DraftOrder } from "./DraftOrder"
export type { Error } from "./Error"
export type { ExtendedReservationItem } from "./ExtendedReservationItem"
export type { ExtendedStoreDTO } from "./ExtendedStoreDTO"
export type { FeatureFlagsResponse } from "./FeatureFlagsResponse"
export type { Fulfillment } from "./Fulfillment"

View File

@@ -1,19 +1,20 @@
import {
AdminGetInventoryItemsParams,
AdminInventoryItemsRes,
AdminPostInventoryItemsInventoryItemReq,
AdminGetInventoryItemsItemLocationLevelsParams,
AdminPostInventoryItemsItemLocationLevelsLevelReq,
AdminInventoryItemsDeleteRes,
AdminGetInventoryItemsItemParams,
AdminGetInventoryItemsParams,
AdminInventoryItemsDeleteRes,
AdminInventoryItemsListWithVariantsAndLocationLevelsRes,
AdminInventoryItemsLocationLevelsRes,
AdminInventoryItemsRes,
AdminPostInventoryItemsInventoryItemReq,
AdminPostInventoryItemsItemLocationLevelsLevelReq,
AdminPostInventoryItemsItemLocationLevelsReq,
AdminPostInventoryItemsReq,
AdminPostInventoryItemsParams,
AdminPostInventoryItemsReq,
} from "@medusajs/medusa"
import { ResponsePromise } from "../../typings"
import BaseResource from "../base"
import { ResponsePromise } from "../../typings"
import qs from "qs"
class AdminInventoryItemsResource extends BaseResource {
@@ -202,7 +203,7 @@ class AdminInventoryItemsResource extends BaseResource {
query?: AdminGetInventoryItemsItemLocationLevelsParams,
customHeaders: Record<string, any> = {}
): ResponsePromise<AdminInventoryItemsLocationLevelsRes> {
let path = `/admin/inventory-items/${inventoryItemId}`
let path = `/admin/inventory-items/${inventoryItemId}/location-levels`
if (query) {
const queryString = qs.stringify(query)

View File

@@ -1,14 +1,16 @@
import {
AdminGetInventoryItemsItemLocationLevelsParams,
AdminGetStockLocationsParams,
AdminInventoryItemsListWithVariantsAndLocationLevelsRes,
AdminInventoryItemsLocationLevelsRes,
AdminInventoryItemsRes,
} from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { useQuery } from "@tanstack/react-query"
import { useMedusa } from "../../../contexts"
import { UseQueryOptionsWrapper } from "../../../types"
import { queryKeysFactory } from "../../utils"
import { useMedusa } from "../../../contexts"
import { useQuery } from "@tanstack/react-query"
const ADMIN_INVENTORY_ITEMS_QUERY_KEY = `admin_inventory_items` as const
@@ -59,7 +61,7 @@ export const useAdminInventoryItem = (
export const useAdminInventoryItemLocationLevels = (
inventoryItemId: string,
query?: AdminGetStockLocationsParams,
query?: AdminGetInventoryItemsItemLocationLevelsParams,
options?: UseQueryOptionsWrapper<
Response<AdminInventoryItemsLocationLevelsRes>,
Error,

View File

@@ -11,10 +11,11 @@ import {
} from "@tanstack/react-query"
import { Response } from "@medusajs/medusa-js/src"
import { adminInventoryItemsKeys } from "../inventory-item"
import { adminReservationsKeys } from "./queries"
import { adminVariantKeys } from "../variants"
import { buildOptions } from "../../utils/buildOptions"
import { useMedusa } from "../../../contexts"
import { adminVariantKeys } from "../variants"
export const useAdminCreateReservation = (
options?: UseMutationOptions<
@@ -57,6 +58,7 @@ export const useAdminUpdateReservation = (
adminReservationsKeys.lists(),
adminReservationsKeys.detail(id),
adminVariantKeys.all,
adminInventoryItemsKeys.details()
],
options
)

View File

@@ -222,6 +222,38 @@ export type AdminInventoryItemsListRes = PaginatedResponse & {
inventory_items: InventoryItemDTO[]
}
/**
* @schema DecoratedInventoryItemDTO
* type: object
* allOf:
* - $ref: "#/components/schemas/InventoryItemDTO"
* - type: object
* required:
* - stocked_quantity
* - reserved_quantity
* properties:
* location_levels:
* type: array
* items:
* $ref: "#/components/schemas/InventoryLevelDTO"
* variants:
* type: array
* items:
* $ref: "#/components/schemas/ProductVariant"
* stocked_quantity:
* type: number
* description: The total quantity of the item in stock across levels
* reserved_quantity:
* type: number
* description: The total quantity of the item available across levels
*/
export type DecoratedInventoryItemDTO = InventoryItemDTO & {
location_levels?: InventoryLevelDTO[]
variants?: ProductVariant[]
stocked_quantity: number
reserved_quantity: number
}
/**
* @schema AdminInventoryItemsListWithVariantsAndLocationLevelsRes
* type: object
@@ -234,20 +266,7 @@ export type AdminInventoryItemsListRes = PaginatedResponse & {
* inventory_items:
* type: array
* items:
* allOf:
* - $ref: "#/components/schemas/InventoryItemDTO"
* - type: object
* properties:
* location_levels:
* type: array
* items:
* allOf:
* - $ref: "#/components/schemas/InventoryLevelDTO"
* variants:
* type: array
* items:
* allOf:
* - $ref: "#/components/schemas/ProductVariant"
* $ref: "#/components/schemas/DecoratedInventoryItemDTO"
* count:
* type: integer
* description: The total number of items available
@@ -260,11 +279,9 @@ export type AdminInventoryItemsListRes = PaginatedResponse & {
*/
export type AdminInventoryItemsListWithVariantsAndLocationLevelsRes =
PaginatedResponse & {
inventory_items: (Partial<InventoryItemDTO> & {
location_levels?: InventoryLevelDTO[]
variants?: ProductVariant[]
})[]
inventory_items: DecoratedInventoryItemDTO[]
}
/**
* @schema AdminInventoryItemsLocationLevelsRes
* type: object

View File

@@ -1,19 +1,23 @@
import { IInventoryService } from "@medusajs/types"
import { Transform } from "class-transformer"
import { IsBoolean, IsOptional, IsString } from "class-validator"
import { Request, Response } from "express"
import {
NumericalComparisonOperator,
StringComparisonOperator,
extendedFindParamsMixin,
} from "../../../../types/common"
import {
ProductVariantInventoryService,
ProductVariantService,
} from "../../../../services"
import { Request, Response } from "express"
import { getLevelsByInventoryItemId, joinLevels } from "./utils/join-levels"
import {
extendedFindParamsMixin,
NumericalComparisonOperator,
StringComparisonOperator,
} from "../../../../types/common"
getVariantsByInventoryItemId,
joinVariants,
} from "./utils/join-variants"
import { IInventoryService } from "@medusajs/types"
import { IsType } from "../../../../utils/validators/is-type"
import { getLevelsByInventoryItemId } from "./utils/join-levels"
import { getVariantsByInventoryItemId } from "./utils/join-variants"
import { Transform } from "class-transformer"
/**
* @oas [get] /admin/inventory-items
@@ -117,30 +121,16 @@ export default async (req: Request, res: Response) => {
listConfig
)
const levelsByItemId = await getLevelsByInventoryItemId(
inventoryItems,
locationIds,
inventoryService
)
const variantsByInventoryItemId = await getVariantsByInventoryItemId(
const inventory_items = await joinVariants(
inventoryItems,
productVariantInventoryService,
productVariantService
)
const inventoryItemsWithVariantsAndLocationLevels = inventoryItems.map(
(inventoryItem) => {
return {
...inventoryItem,
variants: variantsByInventoryItemId[inventoryItem.id] ?? [],
location_levels: levelsByItemId[inventoryItem.id] ?? [],
}
}
)
).then(async (res) => {
return await joinLevels(res, locationIds, inventoryService)
})
res.status(200).json({
inventory_items: inventoryItemsWithVariantsAndLocationLevels,
inventory_items,
count,
offset: skip,
limit: take,

View File

@@ -62,8 +62,22 @@ export const joinLevels = async (
inventoryService
)
return inventoryItems.map((inventoryItem) => ({
...inventoryItem,
location_levels: levelsByItemId[inventoryItem.id] || [],
}))
return inventoryItems.map((inventoryItem) => {
const levels = levelsByItemId[inventoryItem.id] ?? []
const itemAvailability = levels.reduce(
(acc, curr) => {
return {
reserved_quantity: acc.reserved_quantity + curr.reserved_quantity,
stocked_quantity: acc.stocked_quantity + curr.stocked_quantity,
}
},
{ reserved_quantity: 0, stocked_quantity: 0 }
)
return {
...inventoryItem,
...itemAvailability,
location_levels: levels,
}
})
}

View File

@@ -1,10 +1,11 @@
import { InventoryItemDTO } from "@medusajs/types"
import { ProductVariant } from "../../../../../models"
import {
ProductVariantInventoryService,
ProductVariantService,
} from "../../../../../services"
import { InventoryItemDTO } from "@medusajs/types"
import { ProductVariant } from "../../../../../models"
export type InventoryItemsWithVariants = Partial<InventoryItemDTO> & {
variants?: ProductVariant[]
}
@@ -34,3 +35,22 @@ export const getVariantsByInventoryItemId = async (
return acc
}, {})
}
export const joinVariants = async (
inventoryItems: InventoryItemDTO[],
productVariantInventoryService: ProductVariantInventoryService,
productVariantService: ProductVariantService
) => {
const variantsByInventoryItemId = await getVariantsByInventoryItemId(
inventoryItems,
productVariantInventoryService,
productVariantService
)
return inventoryItems.map((inventoryItem) => {
return {
...inventoryItem,
variants: variantsByInventoryItemId[inventoryItem.id] ?? [],
}
})
}

View File

@@ -121,6 +121,7 @@ export default async (req, res) => {
*/
export class AdminPostReservationsReq {
@IsString()
@IsOptional()
line_item_id?: string
@IsString()

View File

@@ -1,4 +1,5 @@
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
import { InventoryItemDTO, ReservationItemDTO } from "@medusajs/types"
import middlewares, {
transformBody,
transformQuery,
@@ -7,7 +8,7 @@ import middlewares, {
import { AdminGetReservationsParams } from "./list-reservations"
import { AdminPostReservationsReq } from "./create-reservation"
import { AdminPostReservationsReservationReq } from "./update-reservation"
import { ReservationItemDTO } from "@medusajs/types"
import { LineItem } from "../../../../models"
import { Router } from "express"
import { checkRegisteredModules } from "../../../middlewares/check-registered-modules"
@@ -68,6 +69,25 @@ export type AdminReservationsRes = {
reservation: ReservationItemDTO
}
/**
* @schema ExtendedReservationItem
* type: object
* allOf:
* - $ref: "#/components/schemas/ReservationItemDTO"
* - type: object
* properties:
* line_item:
* description: optional line item
* $ref: "#/components/schemas/LineItem"
* inventory_item:
* description: inventory item from inventory module
* $ref: "#/components/schemas/InventoryItemDTO"
*/
export type ExtendedReservationItem = ReservationItemDTO & {
line_item?: LineItem
inventory_item?: InventoryItemDTO
}
/**
* @schema AdminReservationsListRes
* type: object
@@ -80,7 +100,7 @@ export type AdminReservationsRes = {
* reservations:
* type: array
* items:
* $ref: "#/components/schemas/ReservationItemDTO"
* $ref: "#/components/schemas/ExtendedReservationItem"
* count:
* type: integer
* description: The total number of items available

View File

@@ -1,11 +1,17 @@
import { IInventoryService } from "@medusajs/types"
import { Type } from "class-transformer"
import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator"
import { Request, Response } from "express"
import {
extendedFindParamsMixin,
NumericalComparisonOperator,
extendedFindParamsMixin,
} from "../../../../types/common"
import { Request, Response } from "express"
import { EntityManager } from "typeorm"
import { IInventoryService } from "@medusajs/types"
import { IsType } from "../../../../utils/validators/is-type"
import { LineItemService } from "../../../../services"
import { Type } from "class-transformer"
import { joinInventoryItems } from "./utils/join-inventory-items"
import { joinLineItems } from "./utils/join-line-items"
/**
* @oas [get] /admin/reservations
@@ -110,12 +116,47 @@ import {
export default async (req: Request, res: Response) => {
const inventoryService: IInventoryService =
req.scope.resolve("inventoryService")
const manager: EntityManager = req.scope.resolve("manager")
const { filterableFields, listConfig } = req
const relations = new Set(listConfig.relations ?? [])
const includeItems = relations.delete("line_item")
const includeInventoryItems = relations.delete("inventory_item")
if (listConfig.relations?.length) {
listConfig.relations = [...relations]
}
const [reservations, count] = await inventoryService.listReservationItems(
req.filterableFields,
req.listConfig
filterableFields,
listConfig,
{
transactionManager: manager,
}
)
const promises: Promise<any>[] = []
if (includeInventoryItems) {
promises.push(
joinInventoryItems(reservations, {
inventoryService,
manager,
})
)
}
if (includeItems) {
const lineItemService: LineItemService =
req.scope.resolve("lineItemService")
promises.push(joinLineItems(reservations, lineItemService))
}
await Promise.all(promises)
const { limit, offset } = req.validatedQuery
res.json({ reservations, count, limit, offset })
@@ -125,10 +166,9 @@ export class AdminGetReservationsParams extends extendedFindParamsMixin({
limit: 20,
offset: 0,
}) {
@IsArray()
@IsString({ each: true })
@IsOptional()
location_id?: string[]
@IsType([String, [String]])
location_id?: string | string[]
@IsArray()
@IsString({ each: true })

View File

@@ -0,0 +1,32 @@
import { EntityManager } from "typeorm"
import { ExtendedReservationItem } from ".."
import { IInventoryService } from "@medusajs/types"
export const joinInventoryItems = async (
reservations: ExtendedReservationItem[],
dependencies: {
inventoryService: IInventoryService
manager: EntityManager
}
): Promise<ExtendedReservationItem[]> => {
const [inventoryItems] =
await dependencies.inventoryService.listInventoryItems(
{
id: reservations.map((r) => r.inventory_item_id),
},
{},
{
transactionManager: dependencies.manager,
}
)
const inventoryItemMap = new Map(inventoryItems.map((i) => [i.id, i]))
return reservations.map((reservation) => {
reservation.inventory_item = inventoryItemMap.get(
reservation.inventory_item_id
)
return reservation
})
}

View File

@@ -0,0 +1,30 @@
import { ExtendedReservationItem } from ".."
import { LineItemService } from "../../../../../services"
export const joinLineItems = async (
reservations: ExtendedReservationItem[],
lineItemService: LineItemService
): Promise<ExtendedReservationItem[]> => {
const lineItems = await lineItemService.list(
{
id: reservations
.map((r) => r.line_item_id)
.filter((lId: string | null | undefined): lId is string => !!lId),
},
{
relations: ["order"],
}
)
const lineItemMap = new Map(lineItems.map((i) => [i.id, i]))
return reservations.map((reservation) => {
if (!reservation.line_item_id) {
return reservation
}
reservation.line_item = lineItemMap.get(reservation.line_item_id)
return reservation
})
}

View File

@@ -5856,6 +5856,7 @@ __metadata:
"@radix-ui/react-dialog": ^1.0.2
"@radix-ui/react-dropdown-menu": ^2.0.2
"@radix-ui/react-popover": ^1.0.3
"@radix-ui/react-portal": ^1.0.2
"@radix-ui/react-radio-group": ^1.1.1
"@radix-ui/react-select": ^1.2.0
"@radix-ui/react-switch": ^1.0.1
@@ -7726,6 +7727,19 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-portal@npm:^1.0.2":
version: 1.0.2
resolution: "@radix-ui/react-portal@npm:1.0.2"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/react-primitive": 1.0.2
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
checksum: 2397b9a3fe5e1f7b4982e0995fb088f77221754e42ebf5aa52374794ed7d35048ec530cc486927e65b1975fdb5f5caa1d8d2f87e8aa15004dfa011511fe8c606
languageName: node
linkType: hard
"@radix-ui/react-presence@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-presence@npm:1.0.0"
@@ -7753,6 +7767,19 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-primitive@npm:1.0.2":
version: 1.0.2
resolution: "@radix-ui/react-primitive@npm:1.0.2"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/react-slot": 1.0.1
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
checksum: bc9dc28568a9d4e0343363e62428fc13685667e470a663a69413a23145264f7a114de9d45c1ce33e6ccdeb7ae5bbfdbc198ee4b2505cd71da8f8d470c4f88d68
languageName: node
linkType: hard
"@radix-ui/react-radio-group@npm:^1.1.1":
version: 1.1.1
resolution: "@radix-ui/react-radio-group@npm:1.1.1"