From 4f3c8f5d70b5ae4a11e9d4a2fea4a8410b2daf47 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Tue, 23 May 2023 05:24:28 +0200 Subject: [PATCH] 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 * 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 Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Co-authored-by: Riqwan Thamir --- .changeset/tall-dolls-relax.md | 9 + .../inventory/inventory-items/index.js | 2 + .../inventory/reservation-items/index.js | 22 + packages/admin-ui/package.json | 1 + .../molecules/item-search/index.tsx | 110 ++++ .../molecules/location-dropdown/index.tsx | 45 ++ .../components/molecules/modal/side-modal.tsx | 83 ++-- .../select/next-select/select/index.tsx | 9 +- .../select/next-select/use-select-props.tsx | 4 +- .../components/reservation-form/index.tsx | 140 ++++++ .../templates/reservations-table/index.tsx | 361 ++++++++++++++ .../reservations-table/new/index.tsx | 122 +++++ .../use-reservation-filters.ts | 470 ++++++++++++++++++ .../use-reservations-columns.tsx | 41 ++ .../ui/src/domain/inventory/header.tsx | 6 +- .../ui/src/domain/inventory/index.tsx | 2 + .../domain/inventory/reservations/index.tsx | 22 + ...emsListWithVariantsAndLocationLevelsRes.ts | 11 +- .../lib/models/AdminReservationsListRes.ts | 4 +- .../lib/models/DecoratedInventoryItemDTO.ts | 21 + .../src/lib/models/ExtendedReservationItem.ts | 19 + .../client-types/src/lib/models/index.ts | 2 + .../src/resources/admin/inventory-item.ts | 17 +- .../src/hooks/admin/inventory-item/queries.ts | 8 +- .../src/hooks/admin/reservations/mutations.ts | 4 +- .../api/routes/admin/inventory-items/index.ts | 53 +- .../inventory-items/list-inventory-items.ts | 46 +- .../inventory-items/utils/join-levels.ts | 22 +- .../inventory-items/utils/join-variants.ts | 24 +- .../admin/reservations/create-reservation.ts | 1 + .../api/routes/admin/reservations/index.ts | 24 +- .../admin/reservations/list-reservations.ts | 58 ++- .../utils/join-inventory-items.ts | 32 ++ .../reservations/utils/join-line-items.ts | 30 ++ yarn.lock | 27 + 35 files changed, 1717 insertions(+), 135 deletions(-) create mode 100644 .changeset/tall-dolls-relax.md create mode 100644 packages/admin-ui/ui/src/components/molecules/item-search/index.tsx create mode 100644 packages/admin-ui/ui/src/components/molecules/location-dropdown/index.tsx create mode 100644 packages/admin-ui/ui/src/components/templates/reservations-table/components/reservation-form/index.tsx create mode 100644 packages/admin-ui/ui/src/components/templates/reservations-table/index.tsx create mode 100644 packages/admin-ui/ui/src/components/templates/reservations-table/new/index.tsx create mode 100644 packages/admin-ui/ui/src/components/templates/reservations-table/use-reservation-filters.ts create mode 100644 packages/admin-ui/ui/src/components/templates/reservations-table/use-reservations-columns.tsx create mode 100644 packages/admin-ui/ui/src/domain/inventory/reservations/index.tsx create mode 100644 packages/generated/client-types/src/lib/models/DecoratedInventoryItemDTO.ts create mode 100644 packages/generated/client-types/src/lib/models/ExtendedReservationItem.ts create mode 100644 packages/medusa/src/api/routes/admin/reservations/utils/join-inventory-items.ts create mode 100644 packages/medusa/src/api/routes/admin/reservations/utils/join-line-items.ts diff --git a/.changeset/tall-dolls-relax.md b/.changeset/tall-dolls-relax.md new file mode 100644 index 0000000000..a8e935e97a --- /dev/null +++ b/.changeset/tall-dolls-relax.md @@ -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 diff --git a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js index 2fd790bbee..db0c1ef3b2 100644 --- a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js +++ b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js @@ -533,6 +533,8 @@ describe("Inventory Items endpoints", () => { available_quantity: 5, }), ]), + reserved_quantity: 0, + stocked_quantity: 15, }) ) }) diff --git a/integration-tests/plugins/__tests__/inventory/reservation-items/index.js b/integration-tests/plugins/__tests__/inventory/reservation-items/index.js index 8c7536edca..7906f8ba03 100644 --- a/integration-tests/plugins/__tests__/inventory/reservation-items/index.js +++ b/integration-tests/plugins/__tests__/inventory/reservation-items/index.js @@ -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({}), + }), + }), + ]) + ) + }) }) }) diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index 075a911996..2cb6036a7a 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -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", diff --git a/packages/admin-ui/ui/src/components/molecules/item-search/index.tsx b/packages/admin-ui/ui/src/components/molecules/item-search/index.tsx new file mode 100644 index 0000000000..a02096eac5 --- /dev/null +++ b/packages/admin-ui/ui/src/components/molecules/item-search/index.tsx @@ -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() + + const debouncedItemSearchTerm = useDebounce(itemSearchTerm, 500) + + const queryEnabled = !!debouncedItemSearchTerm?.length + + const { isLoading, inventory_items } = useAdminInventoryItems( + { + q: debouncedItemSearchTerm, + ...filters, + }, + { enabled: queryEnabled } + ) + + const onChange = (item: SingleValue) => { + 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 ( +
+ "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 + /> +
+ ) +} + +const ProductOption = ({ innerProps, data }: OptionProps) => { + return ( +
+
+

{data.label}

+

{data.inventoryItem.sku}

+
+
+

{`${data.inventoryItem.stocked_quantity} stock`}

+

{`${ + data.inventoryItem.stocked_quantity - + data.inventoryItem.reserved_quantity + } available`}

+
+
+ ) +} + +const SearchControl = ({ children, ...props }: ControlProps) => ( + + + + + {children} + +) + +export default ItemSearch diff --git a/packages/admin-ui/ui/src/components/molecules/location-dropdown/index.tsx b/packages/admin-ui/ui/src/components/molecules/location-dropdown/index.tsx new file mode 100644 index 0000000000..20a5844421 --- /dev/null +++ b/packages/admin-ui/ui/src/components/molecules/location-dropdown/index.tsx @@ -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 ( + { + onChange(loc!.value) + }} + options={locations.map((l) => ({ + label: l.name, + value: l.id, + }))} + value={{ value: selectedLocObj.id, label: selectedLocObj.name }} + /> + ) +} + +export default LocationDropdown diff --git a/packages/admin-ui/ui/src/components/molecules/modal/side-modal.tsx b/packages/admin-ui/ui/src/components/molecules/modal/side-modal.tsx index 9ecee2081d..a921266efa 100644 --- a/packages/admin-ui/ui/src/components/molecules/modal/side-modal.tsx +++ b/packages/admin-ui/ui/src/components/molecules/modal/side-modal.tsx @@ -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 ( - - {isVisible && ( - <> - - - {children} - - - )} - + + + {isVisible && ( + <> + + + {children} + + + )} + + ) } diff --git a/packages/admin-ui/ui/src/components/molecules/select/next-select/select/index.tsx b/packages/admin-ui/ui/src/components/molecules/select/next-select/select/index.tsx index e096fcddf7..f035b94096 100644 --- a/packages/admin-ui/ui/src/components/molecules/select/next-select/select/index.tsx +++ b/packages/admin-ui/ui/src/components/molecules/select/next-select/select/index.tsx @@ -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 = < diff --git a/packages/admin-ui/ui/src/components/molecules/select/next-select/use-select-props.tsx b/packages/admin-ui/ui/src/components/molecules/select/next-select/use-select-props.tsx index 6875914cd4..4a913fde16 100644 --- a/packages/admin-ui/ui/src/components/molecules/select/next-select/use-select-props.tsx +++ b/packages/admin-ui/ui/src/components/molecules/select/next-select/use-select-props.tsx @@ -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, diff --git a/packages/admin-ui/ui/src/components/templates/reservations-table/components/reservation-form/index.tsx b/packages/admin-ui/ui/src/components/templates/reservations-table/components/reservation-form/index.tsx new file mode 100644 index 0000000000..31cab507be --- /dev/null +++ b/packages/admin-ui/ui/src/components/templates/reservations-table/components/reservation-form/index.tsx @@ -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 + description: string + quantity: number +} + +type Props = { + form: NestedForm +} + +const ReservationForm: React.FC = ({ 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 ( +
+
+
+

Location

+

Choose where you wish to reserve from.

+
+ { + return ( + + ) + }} + /> +
+
+
+

Item to reserve

+

+ Select the item that you wish to reserve. +

+
+ { + return ( + + ) + }} + /> + {selectedItem && locationLevel && ( +
+
*]: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`} + > +
Item
+
+ {selectedItem!.title ?? "N/A"} +
+
SKU
+
{selectedItem.sku ?? "N/A"}
+
In stock
+
{locationLevel?.stocked_quantity}
+
Available
+
+ {locationLevel?.stocked_quantity - + locationLevel?.reserved_quantity} +
+
Reserve
+
+ +
+
+
+ +
+
+ )} +
+
+
+

Description

+

What type of reservation is this?

+
+ +
+
+ ) +} + +export default ReservationForm diff --git a/packages/admin-ui/ui/src/components/templates/reservations-table/index.tsx b/packages/admin-ui/ui/src/components/templates/reservations-table/index.tsx new file mode 100644 index 0000000000..bea3fe2682 --- /dev/null +++ b/packages/admin-ui/ui/src/components/templates/reservations-table/index.tsx @@ -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 ( +
+ { + onChange(loc!.value) + }} + options={locations.map((l) => ({ + label: l.name, + value: l.id, + }))} + value={{ value: selectedLocObj.id, label: selectedLocObj.name }} + /> +
+ ) +} + +const ReservationsTable: React.FC = () => { + 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 ( + <> + + + { + setLocationFilter(id) + gotoPage(0) + }} + /> + + + } + {...getTableProps()} + > + + {headerGroups?.map((headerGroup) => { + const { key, ...rest } = headerGroup.getHeaderGroupProps() + + return ( + + {headerGroup.headers.map((col) => { + const { key, ...rest } = col.getHeaderProps() + return ( + + {col.render("Header")} + + ) + })} + + ) + })} + + + + {rows.map((row) => { + prepareRow(row) + const { key, ...rest } = row.getRowProps() + return + })} + +
+
+ + + + + ) +} + +const ReservationRow = ({ + row, + ...rest +}: { + row: Row +} & TableRowProps) => { + const inventory = row.original + + const { mutate: deleteReservation } = useAdminDeleteReservation(inventory.id) + + const [showEditReservation, setShowEditReservation] = + useState(null) + const [showDeleteReservation, setShowDeleteReservation] = useState(false) + + const getRowActionables = () => { + const actions = [ + { + label: "Edit", + onClick: () => setShowEditReservation(row.original), + icon: , + }, + { + label: "Delete", + variant: "danger", + icon: , + onClick: () => setShowDeleteReservation(true), + }, + ] + + return actions + } + + return ( + <> + + {row.cells.map((cell: Cell, index: number) => { + const { key, ...rest } = cell.getCellProps() + return ( + + {cell.render("Cell", { index })} + + ) + })} + + {showEditReservation && ( + setShowEditReservation(null)} + reservation={row.original} + /> + )} + {showDeleteReservation && ( + await deleteReservation(undefined)} + handleClose={() => setShowDeleteReservation(false)} + /> + )} + + ) +} + +export default ReservationsTable diff --git a/packages/admin-ui/ui/src/components/templates/reservations-table/new/index.tsx b/packages/admin-ui/ui/src/components/templates/reservations-table/new/index.tsx new file mode 100644 index 0000000000..6d634f99e3 --- /dev/null +++ b/packages/admin-ui/ui/src/components/templates/reservations-table/new/index.tsx @@ -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({ + 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 ( +
+ + +
+ +
+ + +
+
+
+ +
+

+ Reserve Item +

+
+ +
+
+

Metadata

+ +
+
+
+
+
+ ) +} + +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 diff --git a/packages/admin-ui/ui/src/components/templates/reservations-table/use-reservation-filters.ts b/packages/admin-ui/ui/src/components/templates/reservations-table/use-reservation-filters.ts new file mode 100644 index 0000000000..9aafa2a3fb --- /dev/null +++ b/packages/admin-ui/ui/src/components/templates/reservations-table/use-reservation-filters.ts @@ -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, bs: Set) => { + 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 +} diff --git a/packages/admin-ui/ui/src/components/templates/reservations-table/use-reservations-columns.tsx b/packages/admin-ui/ui/src/components/templates/reservations-table/use-reservations-columns.tsx new file mode 100644 index 0000000000..681358dd4b --- /dev/null +++ b/packages/admin-ui/ui/src/components/templates/reservations-table/use-reservations-columns.tsx @@ -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: () =>
Quantity
, + accessor: "quantity", + Cell: ({ cell: { value } }) => ( +
{value}
+ ), + }, + ], + [] + ) + + return [columns] as const +} + +export default useReservationsTableColumns diff --git a/packages/admin-ui/ui/src/domain/inventory/header.tsx b/packages/admin-ui/ui/src/domain/inventory/header.tsx index faba8ebd44..213dd7577c 100644 --- a/packages/admin-ui/ui/src/domain/inventory/header.tsx +++ b/packages/admin-ui/ui/src/domain/inventory/header.tsx @@ -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} /> ) diff --git a/packages/admin-ui/ui/src/domain/inventory/index.tsx b/packages/admin-ui/ui/src/domain/inventory/index.tsx index c1ac845fee..01ae3d98b8 100644 --- a/packages/admin-ui/ui/src/domain/inventory/index.tsx +++ b/packages/admin-ui/ui/src/domain/inventory/index.tsx @@ -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 ( } /> } /> + } /> ) } diff --git a/packages/admin-ui/ui/src/domain/inventory/reservations/index.tsx b/packages/admin-ui/ui/src/domain/inventory/reservations/index.tsx new file mode 100644 index 0000000000..7c01fe6b9b --- /dev/null +++ b/packages/admin-ui/ui/src/domain/inventory/reservations/index.tsx @@ -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 ( +
+
+ } + className="h-fit" + > + + + +
+
+ ) +} + +export default Reservations diff --git a/packages/generated/client-types/src/lib/models/AdminInventoryItemsListWithVariantsAndLocationLevelsRes.ts b/packages/generated/client-types/src/lib/models/AdminInventoryItemsListWithVariantsAndLocationLevelsRes.ts index 907bdb079e..9eb7b92633 100644 --- a/packages/generated/client-types/src/lib/models/AdminInventoryItemsListWithVariantsAndLocationLevelsRes.ts +++ b/packages/generated/client-types/src/lib/models/AdminInventoryItemsListWithVariantsAndLocationLevelsRes.ts @@ -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 - variants?: Array - } - > + inventory_items: Array /** * The total number of items available */ diff --git a/packages/generated/client-types/src/lib/models/AdminReservationsListRes.ts b/packages/generated/client-types/src/lib/models/AdminReservationsListRes.ts index dcb5d97842..3b3ff52777 100644 --- a/packages/generated/client-types/src/lib/models/AdminReservationsListRes.ts +++ b/packages/generated/client-types/src/lib/models/AdminReservationsListRes.ts @@ -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 + reservations: Array /** * The total number of items available */ diff --git a/packages/generated/client-types/src/lib/models/DecoratedInventoryItemDTO.ts b/packages/generated/client-types/src/lib/models/DecoratedInventoryItemDTO.ts new file mode 100644 index 0000000000..25810b4144 --- /dev/null +++ b/packages/generated/client-types/src/lib/models/DecoratedInventoryItemDTO.ts @@ -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 + variants?: Array + /** + * 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 +} diff --git a/packages/generated/client-types/src/lib/models/ExtendedReservationItem.ts b/packages/generated/client-types/src/lib/models/ExtendedReservationItem.ts new file mode 100644 index 0000000000..f6aa77f3e5 --- /dev/null +++ b/packages/generated/client-types/src/lib/models/ExtendedReservationItem.ts @@ -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 +} diff --git a/packages/generated/client-types/src/lib/models/index.ts b/packages/generated/client-types/src/lib/models/index.ts index 6d280e52f0..dd2226255a 100644 --- a/packages/generated/client-types/src/lib/models/index.ts +++ b/packages/generated/client-types/src/lib/models/index.ts @@ -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" diff --git a/packages/medusa-js/src/resources/admin/inventory-item.ts b/packages/medusa-js/src/resources/admin/inventory-item.ts index c926dbc689..c77223b28d 100644 --- a/packages/medusa-js/src/resources/admin/inventory-item.ts +++ b/packages/medusa-js/src/resources/admin/inventory-item.ts @@ -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 = {} ): ResponsePromise { - let path = `/admin/inventory-items/${inventoryItemId}` + let path = `/admin/inventory-items/${inventoryItemId}/location-levels` if (query) { const queryString = qs.stringify(query) diff --git a/packages/medusa-react/src/hooks/admin/inventory-item/queries.ts b/packages/medusa-react/src/hooks/admin/inventory-item/queries.ts index 39172b0fda..f824c6e7b0 100644 --- a/packages/medusa-react/src/hooks/admin/inventory-item/queries.ts +++ b/packages/medusa-react/src/hooks/admin/inventory-item/queries.ts @@ -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, Error, diff --git a/packages/medusa-react/src/hooks/admin/reservations/mutations.ts b/packages/medusa-react/src/hooks/admin/reservations/mutations.ts index 00a7914bb9..c446adf276 100644 --- a/packages/medusa-react/src/hooks/admin/reservations/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/reservations/mutations.ts @@ -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 ) diff --git a/packages/medusa/src/api/routes/admin/inventory-items/index.ts b/packages/medusa/src/api/routes/admin/inventory-items/index.ts index dd008c868d..13701b1f5f 100644 --- a/packages/medusa/src/api/routes/admin/inventory-items/index.ts +++ b/packages/medusa/src/api/routes/admin/inventory-items/index.ts @@ -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 & { - location_levels?: InventoryLevelDTO[] - variants?: ProductVariant[] - })[] + inventory_items: DecoratedInventoryItemDTO[] } + /** * @schema AdminInventoryItemsLocationLevelsRes * type: object diff --git a/packages/medusa/src/api/routes/admin/inventory-items/list-inventory-items.ts b/packages/medusa/src/api/routes/admin/inventory-items/list-inventory-items.ts index 513413df0e..4efc68cb4c 100644 --- a/packages/medusa/src/api/routes/admin/inventory-items/list-inventory-items.ts +++ b/packages/medusa/src/api/routes/admin/inventory-items/list-inventory-items.ts @@ -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, diff --git a/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts index f8e6631296..1edfdfd84a 100644 --- a/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts +++ b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-levels.ts @@ -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, + } + }) } diff --git a/packages/medusa/src/api/routes/admin/inventory-items/utils/join-variants.ts b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-variants.ts index 4d73d3d33f..63c83d1af7 100644 --- a/packages/medusa/src/api/routes/admin/inventory-items/utils/join-variants.ts +++ b/packages/medusa/src/api/routes/admin/inventory-items/utils/join-variants.ts @@ -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 & { 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] ?? [], + } + }) +} diff --git a/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts b/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts index 7a47b8d3a8..489a2e0951 100644 --- a/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts +++ b/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts @@ -121,6 +121,7 @@ export default async (req, res) => { */ export class AdminPostReservationsReq { @IsString() + @IsOptional() line_item_id?: string @IsString() diff --git a/packages/medusa/src/api/routes/admin/reservations/index.ts b/packages/medusa/src/api/routes/admin/reservations/index.ts index ad548101ce..33eda25a3e 100644 --- a/packages/medusa/src/api/routes/admin/reservations/index.ts +++ b/packages/medusa/src/api/routes/admin/reservations/index.ts @@ -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 diff --git a/packages/medusa/src/api/routes/admin/reservations/list-reservations.ts b/packages/medusa/src/api/routes/admin/reservations/list-reservations.ts index 45e8ed8af8..3f8aa20b3d 100644 --- a/packages/medusa/src/api/routes/admin/reservations/list-reservations.ts +++ b/packages/medusa/src/api/routes/admin/reservations/list-reservations.ts @@ -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[] = [] + + 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 }) diff --git a/packages/medusa/src/api/routes/admin/reservations/utils/join-inventory-items.ts b/packages/medusa/src/api/routes/admin/reservations/utils/join-inventory-items.ts new file mode 100644 index 0000000000..e7e6d4b7bd --- /dev/null +++ b/packages/medusa/src/api/routes/admin/reservations/utils/join-inventory-items.ts @@ -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 => { + 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 + }) +} diff --git a/packages/medusa/src/api/routes/admin/reservations/utils/join-line-items.ts b/packages/medusa/src/api/routes/admin/reservations/utils/join-line-items.ts new file mode 100644 index 0000000000..fc40a14ac0 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/reservations/utils/join-line-items.ts @@ -0,0 +1,30 @@ +import { ExtendedReservationItem } from ".." +import { LineItemService } from "../../../../../services" + +export const joinLineItems = async ( + reservations: ExtendedReservationItem[], + lineItemService: LineItemService +): Promise => { + 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 + }) +} diff --git a/yarn.lock b/yarn.lock index b4b9a8d569..b6e0464b69 100644 --- a/yarn.lock +++ b/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"