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:
9
.changeset/tall-dolls-relax.md
Normal file
9
.changeset/tall-dolls-relax.md
Normal 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
|
||||
@@ -533,6 +533,8 @@ describe("Inventory Items endpoints", () => {
|
||||
available_quantity: 5,
|
||||
}),
|
||||
]),
|
||||
reserved_quantity: 0,
|
||||
stocked_quantity: 15,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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({}),
|
||||
}),
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = <
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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] ?? [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ export default async (req, res) => {
|
||||
*/
|
||||
export class AdminPostReservationsReq {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
line_item_id?: string
|
||||
|
||||
@IsString()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
27
yarn.lock
27
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user