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

* add location filtering to list-location levels

* cleanup

* add location filtering to list-location levels

* cleanup

* Initial work on route,table,new reservation form

* generated types

* add block

* udpate clients

* initial create reservation

* update actionables for reservation table

* update edit-allocation modal

* misc naming updates

* update reservations table

* add expand capabilities for list-reservations

* expand fields and show columns

* update oas

* make remove item work in focus modal

* add yarn lock

* add integration test

* Fix display when label doesn't match search term

* remove unused file

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

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

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

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

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

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

* initial changes

* add changeset

* update font size

* cleanup reservations table + select

* add decorated inventory item type

* use type

* feedback changes

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

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

* decorate response for list inventory item to include total quantities

* update decorated properties

* decorate type

* adrien feedback

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

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

* update join-utils

* fix caching

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,470 @@
import { useMemo, useReducer, useState } from "react"
import { omit } from "lodash"
import qs from "qs"
import { relativeDateFormatToTimestamp } from "../../../utils/time"
type ReservationDateFilter = null | {
gt?: string
lt?: string
}
type reservationFilterAction =
| { type: "setQuery"; payload: string | null }
| { type: "setFilters"; payload: ReservationFilterState }
| { type: "reset"; payload: ReservationFilterState }
| { type: "setOffset"; payload: number }
| { type: "setDefaults"; payload: ReservationDefaultFilters | null }
| { type: "setLocation"; payload: string }
| { type: "setLimit"; payload: number }
interface ReservationFilterState {
query?: string | null
limit: number
offset: number
location: string
additionalFilters: ReservationDefaultFilters | null
}
const allowedFilters = ["q", "offset", "limit", "location_id"]
const DefaultTabs = {}
const formatDateFilter = (filter: ReservationDateFilter) => {
if (filter === null) {
return filter
}
const dateFormatted = Object.entries(filter).reduce((acc, [key, value]) => {
if (value.includes("|")) {
acc[key] = relativeDateFormatToTimestamp(value)
} else {
acc[key] = value
}
return acc
}, {})
return dateFormatted
}
const reducer = (
state: ReservationFilterState,
action: reservationFilterAction
): ReservationFilterState => {
switch (action.type) {
case "setFilters": {
return {
...state,
query: action?.payload?.query,
}
}
case "setQuery": {
return {
...state,
query: action.payload,
}
}
case "setLimit": {
return {
...state,
limit: action.payload,
}
}
case "setOffset": {
return {
...state,
offset: action.payload,
}
}
case "setLocation": {
return {
...state,
location: action.payload,
}
}
case "reset": {
return action.payload
}
default: {
return state
}
}
}
type ReservationDefaultFilters = {
expand?: string
fields?: string
location_id?: string
}
const eqSet = (as: Set<string>, bs: Set<string>) => {
if (as.size !== bs.size) {
return false
}
for (const a of as) {
if (!bs.has(a)) {
return false
}
}
return true
}
export const useReservationFilters = (
existing?: string,
defaultFilters: ReservationDefaultFilters | null = null
) => {
if (existing && existing[0] === "?") {
existing = existing.substring(1)
}
const initial = useMemo(
() => parseQueryString(existing, defaultFilters),
[existing, defaultFilters]
)
const initialTabs = useMemo(() => {
const storageString = localStorage.getItem("reservation::filters")
if (storageString) {
const savedTabs = JSON.parse(storageString)
if (savedTabs) {
return Object.entries(savedTabs).map(([key, value]) => {
return {
label: key,
value: key,
removable: true,
representationString: value,
}
})
}
}
return []
}, [])
const [state, dispatch] = useReducer(reducer, initial)
const [tabs, setTabs] = useState(initialTabs)
const setDefaultFilters = (filters: ReservationDefaultFilters | null) => {
dispatch({ type: "setDefaults", payload: filters })
}
const setLimit = (limit: number) => {
dispatch({ type: "setLimit", payload: limit })
}
const setLocationFilter = (loc: string) => {
dispatch({ type: "setLocation", payload: loc })
dispatch({ type: "setOffset", payload: 0 })
}
const paginate = (direction: 1 | -1) => {
if (direction > 0) {
const nextOffset = state.offset + state.limit
dispatch({ type: "setOffset", payload: nextOffset })
} else {
const nextOffset = Math.max(state.offset - state.limit, 0)
dispatch({ type: "setOffset", payload: nextOffset })
}
}
const reset = () => {
dispatch({
type: "setFilters",
payload: {
...state,
offset: 0,
query: null,
},
})
}
const setFilters = (filters: ReservationFilterState) => {
dispatch({ type: "setFilters", payload: filters })
}
const setQuery = (queryString: string | null) => {
dispatch({ type: "setQuery", payload: queryString })
}
const getQueryObject = () => {
const toQuery: any = { ...state.additionalFilters }
for (const [key, value] of Object.entries(state)) {
if (key === "query") {
if (value && typeof value === "string") {
toQuery["q"] = value
}
} else if (key === "offset" || key === "limit") {
toQuery[key] = value
} else if (value.open) {
if (key === "date") {
toQuery[stateFilterMap[key]] = formatDateFilter(
value.filter as ReservationDateFilter
)
} else {
toQuery[stateFilterMap[key]] = value.filter
}
} else if (key === "location") {
toQuery[stateFilterMap[key]] = value
}
}
toQuery["expand"] = "line_item,inventory_item"
return toQuery
}
const getQueryString = () => {
const obj = getQueryObject()
return qs.stringify(obj, { skipNulls: true })
}
const getRepresentationObject = (fromObject?: ReservationFilterState) => {
const objToUse = fromObject ?? state
const toQuery: any = {}
for (const [key, value] of Object.entries(objToUse)) {
if (key === "query") {
if (value && typeof value === "string") {
toQuery["q"] = value
}
} else if (key === "offset" || key === "limit" || key === "location") {
toQuery[stateFilterMap[key] || key] = value
} else if (value.open) {
toQuery[stateFilterMap[key]] = value.filter
}
}
return toQuery
}
const getRepresentationString = () => {
const obj = getRepresentationObject()
return qs.stringify(obj, { skipNulls: true })
}
const queryObject = useMemo(() => getQueryObject(), [state])
const representationObject = useMemo(() => getRepresentationObject(), [state])
const representationString = useMemo(() => getRepresentationString(), [state])
const activeFilterTab = useMemo(() => {
const clean = omit(representationObject, ["limit", "offset"])
const stringified = qs.stringify(clean)
const existsInSaved = tabs.find(
(el) => el.representationString === stringified
)
if (existsInSaved) {
return existsInSaved.value
}
for (const [tab, conditions] of Object.entries(DefaultTabs)) {
let match = true
if (Object.keys(clean).length !== Object.keys(conditions).length) {
continue
}
for (const [filter, value] of Object.entries(conditions)) {
if (filter in clean) {
if (Array.isArray(value)) {
match =
Array.isArray(clean[filter]) &&
eqSet(new Set(clean[filter]), new Set(value))
} else {
match = clean[filter] === value
}
} else {
match = false
}
if (!match) {
break
}
}
if (match) {
return tab
}
}
return null
}, [representationObject, tabs])
const availableTabs = useMemo(() => {
return [...tabs]
}, [tabs])
const setTab = (tabName: string) => {
let tabToUse: object | null = null
if (tabName in DefaultTabs) {
tabToUse = DefaultTabs[tabName]
} else {
const tabFound = tabs.find((t) => t.value === tabName)
if (tabFound) {
tabToUse = qs.parse(tabFound.representationString)
}
}
if (tabToUse) {
const toSubmit = {
...state,
}
for (const [filter, val] of Object.entries(tabToUse)) {
toSubmit[filterStateMap[filter]] = {
open: true,
filter: val,
}
}
dispatch({ type: "setFilters", payload: toSubmit })
}
}
const saveTab = (tabName: string, filters: ReservationFilterState) => {
const repObj = getRepresentationObject({ ...filters })
const clean = omit(repObj, ["limit", "offset"])
const repString = qs.stringify(clean, { skipNulls: true })
const storedString = localStorage.getItem("inventory::filters")
let existing: null | object = null
if (storedString) {
existing = JSON.parse(storedString)
}
if (existing) {
existing[tabName] = repString
localStorage.setItem("inventory::filters", JSON.stringify(existing))
} else {
const newFilters = {}
newFilters[tabName] = repString
localStorage.setItem("inventory::filters", JSON.stringify(newFilters))
}
setTabs((prev) => {
const duplicate = prev.findIndex(
(prev) => prev.label?.toLowerCase() === tabName.toLowerCase()
)
if (duplicate !== -1) {
prev.splice(duplicate, 1)
}
return [
...prev,
{
label: tabName,
value: tabName,
representationString: repString,
removable: true,
},
]
})
dispatch({ type: "setFilters", payload: filters })
}
const removeTab = (tabValue: string) => {
const storedString = localStorage.getItem("products::filters")
let existing: null | object = null
if (storedString) {
existing = JSON.parse(storedString)
}
if (existing) {
delete existing[tabValue]
localStorage.setItem("products::filters", JSON.stringify(existing))
}
setTabs((prev) => {
const newTabs = prev.filter((p) => p.value !== tabValue)
return newTabs
})
}
return {
...state,
filters: {
...state,
},
removeTab,
saveTab,
setTab,
availableTabs,
activeFilterTab,
representationObject,
representationString,
queryObject,
paginate,
getQueryObject,
getQueryString,
setQuery,
setFilters,
setDefaultFilters,
setLocationFilter,
setLimit,
reset,
}
}
const filterStateMap = {
location_id: "location",
}
const stateFilterMap = {
location: "location_id",
}
const parseQueryString = (
queryString?: string,
additionals: ReservationDefaultFilters | null = null
): ReservationFilterState => {
const defaultVal: ReservationFilterState = {
location: additionals?.location_id ?? "",
offset: 0,
limit: 15,
additionalFilters: additionals,
}
if (queryString) {
const filters = qs.parse(queryString)
for (const [key, value] of Object.entries(filters)) {
if (allowedFilters.includes(key)) {
switch (key) {
case "offset": {
if (typeof value === "string") {
defaultVal.offset = parseInt(value)
}
break
}
case "limit": {
if (typeof value === "string") {
defaultVal.limit = parseInt(value)
}
break
}
case "location_id": {
if (typeof value === "string") {
defaultVal.location = value
}
break
}
case "q": {
if (typeof value === "string") {
defaultVal.query = value
}
break
}
default: {
break
}
}
}
}
}
return defaultVal
}

View File

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