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:
@@ -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
|
||||
Reference in New Issue
Block a user