feat(admin-ui): Filter reservations (#4115)

* initial filter

* clenaup

* reser filters correctly

* filter reservations

* ensure reset works

* update types

* add adjustment icon

* pr prep

* update filtering with proper description handling

* location filter updates and search removal

* removed greyed out dates + add created_by filtering

* update filtering with proper ordering

* filter out selected users

* fix array issues

* update spacing for searchable queries

* fix deselection bug for inventory item search

* update date filter;

* rename const a to initialFilters

* fix re-render issue

* ui updates

* update inventory filter to remove selected items

* fix width

* fix truncation for button text if desired

* add span classes

* add "go to reservations" popover

* add tooltip if location text is truncated

* fix long items

* typing

* minor fix for select value

* fix reservation quantity field updates

* add pb

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

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

* feedback

* add changeset

---------

Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Philip Korsholm
2023-06-08 17:57:39 +02:00
committed by GitHub
parent f7376f09fe
commit 79cca2ab80
22 changed files with 1412 additions and 358 deletions
@@ -1,6 +1,12 @@
import { useMemo } from "react"
import * as RadixPopover from "@radix-ui/react-popover"
import { Navigate, useNavigate } from "react-router-dom"
import Button from "../../fundamentals/button"
import ImagePlaceholder from "../../fundamentals/image-placeholder"
import { InventoryLevelDTO } from "@medusajs/types"
import Tooltip from "../../atoms/tooltip"
import { useMemo } from "react"
const useInventoryTableColumn = () => {
const columns = useMemo(
@@ -38,28 +44,55 @@ const useInventoryTableColumn = () => {
Cell: ({ cell: { value } }) => value,
},
{
Header: "Incoming",
accessor: "incoming_quantity",
Cell: ({ row: { original } }) => (
<div>
{original.location_levels.reduce(
(acc, next) => acc + next.incoming_quantity,
0
)}
</div>
),
Header: "Reserved",
accessor: "reserved_quantity",
Cell: ({ row: { original } }) => {
const navigate = useNavigate()
return (
<div className="flex grow">
<Tooltip
content={
<Button
size="small"
className="inter-small-regular w-full"
variant="ghost"
onClick={() => {
navigate(
`/a/inventory/reservations?inventory_item_id%5B0%5D=${original.id}`
)
}}
>
Go to reservations
</Button>
}
>
<div>
{original.location_levels.reduce(
(acc: number, next: InventoryLevelDTO) =>
acc + next.reserved_quantity,
0
)}
</div>
</Tooltip>
</div>
)
},
},
{
Header: "In stock",
accessor: "stocked_quantity",
Cell: ({ row: { original } }) => (
<div>
{original.location_levels.reduce(
(acc, next) => acc + next.stocked_quantity,
0
)}
</div>
),
Cell: ({ row: { original } }) => {
return (
<div className="bg-green-20">
{original.location_levels.reduce(
(acc: number, next: InventoryLevelDTO) =>
acc + next.stocked_quantity,
0
)}
</div>
)
},
},
],
[]
@@ -1,16 +1,16 @@
import Button from "../../../../fundamentals/button"
import { Controller } from "react-hook-form"
import { DecoratedInventoryItemDTO } from "@medusajs/medusa"
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
location: string | undefined
item: Partial<DecoratedInventoryItemDTO> | undefined
description: string | undefined
quantity: number
}
@@ -21,8 +21,12 @@ type Props = {
const ReservationForm: React.FC<Props> = ({ form }) => {
const { register, path, watch, control, setValue } = form
const locationId = watch(path("location"))
const selectedItem = watch(path("item"))
const selectedLocation = watch(path("location"))
const [selectedLocation, setSelectedLocation] = React.useState<
string | undefined
>(locationId)
const locationLevel = selectedItem?.location_levels?.find(
(l) => l.location_id === selectedLocation
@@ -41,7 +45,10 @@ const ReservationForm: React.FC<Props> = ({ form }) => {
render={({ field: { onChange } }) => {
return (
<LocationDropdown
onChange={onChange}
onChange={(v) => {
onChange(v)
setSelectedLocation(v)
}}
selectedLocation={selectedLocation}
/>
)
@@ -0,0 +1,906 @@
import * as RadixCollapsible from "@radix-ui/react-collapsible"
import * as RadixPopover from "@radix-ui/react-popover"
import { DateComparisonOperator, InventoryItemDTO } from "@medusajs/types"
import React, { useEffect, useState } from "react"
import { useAdminInventoryItems, useAdminUsers, useMedusa } from "medusa-react"
import AdjustmentsIcon from "../../../../fundamentals/icons/adjustments-icon"
import Button from "../../../../fundamentals/button"
import { CalendarComponent } from "../../../../atoms/date-picker/date-picker"
import CalendarIcon from "../../../../fundamentals/icons/calendar-icon"
import CheckIcon from "../../../../fundamentals/icons/check-icon"
import CrossIcon from "../../../../fundamentals/icons/cross-icon"
import FilterDropdownContainer from "../../../../molecules/filter-dropdown/container"
import InputField from "../../../../molecules/input"
import Spinner from "../../../../atoms/spinner"
import Switch from "../../../../atoms/switch"
import TagDotIcon from "../../../../fundamentals/icons/tag-dot-icon"
import { User } from "@medusajs/medusa"
import clsx from "clsx"
import moment from "moment"
import { removeNullish } from "../../../../../utils/remove-nullish"
type PasswordlessUser = Omit<User, "password_hash">
const ReservationsFilters = ({ filters, submitFilters, clearFilters }) => {
const [tempState, setTempState] = useState(filters)
useEffect(() => {
setTempState(filters)
}, [filters])
const onSubmit = () => {
const { additionalFilters, ...state } = tempState
submitFilters({
...removeNullish(state),
additionalFilters: removeNullish(additionalFilters),
})
}
const onClear = () => {
clearFilters()
tempState({ ...tempState, additionalFilters: {} })
}
return (
<div className="flex space-x-1">
<FilterDropdownContainer
submitFilters={onSubmit}
clearFilters={onClear}
triggerElement={
<Button variant="secondary" size="small">
<AdjustmentsIcon size={20} />
View
</Button>
}
>
<div className="w-[320px]">
<SearchableFilterInventoryItem
title="Inventory item"
value={tempState.additionalFilters.inventory_item_id}
setFilter={(val) => {
setTempState(({ additionalFilters, ...state }) => {
return {
...state,
additionalFilters: {
...additionalFilters,
inventory_item_id: val,
},
}
})
}}
/>
<TextFilterItem
title="Description"
value={tempState.additionalFilters.description}
options={[
{ label: "Equals", value: "equals" },
{ label: "Contains", value: "contains" },
]}
setFilter={(val) => {
setTempState((state) => {
return {
...state,
additionalFilters: {
description: val,
},
}
})
}}
/>
<DateFilterItem
title="Creation date"
value={tempState.additionalFilters.created_at}
setFilter={(val) => {
setTempState(({ additionalFilters, ...state }) => {
return {
...state,
additionalFilters: { ...additionalFilters, created_at: val },
}
})
}}
/>
<NumberFilterItem
title="Quantity"
value={tempState.additionalFilters.quantity}
options={[
{ label: "Over", value: "gt" },
{ label: "Under", value: "lt" },
{ label: "Between", value: "between" },
]}
setFilter={(val) => {
setTempState(({ additionalFilters, ...state }) => {
return {
...state,
additionalFilters: { ...additionalFilters, quantity: val },
}
})
}}
/>
<CreatedByFilterItem
title="Created by"
value={tempState.additionalFilters.created_by}
setFilter={(val) => {
setTempState(({ additionalFilters, ...state }) => {
return {
...state,
additionalFilters: { ...additionalFilters, created_by: val },
}
})
}}
/>
</div>
</FilterDropdownContainer>
</div>
)
}
const SearchableFilterInventoryItem = ({
title,
setFilter,
value,
}: {
title: string
value: any
setFilter: (newFilter: any) => void
}) => {
const [selectedItems, setSelectedItems] = useState<Set<InventoryItemDTO>>(
new Set()
)
const [searchTerm, setSearchTerm] = useState<string>("")
const [query, setQuery] = useState<string | undefined>()
const { client } = useMedusa()
useEffect(() => {
const getSelectedItems = async (value: any) => {
if (value?.length) {
const { inventory_items } = await client.admin.inventoryItems.list({
id: [...new Set(value)] as string[],
})
setSelectedItems(new Set(inventory_items))
}
}
getSelectedItems(value)
}, [client.admin.inventoryItems, value])
// Debounced search
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
setSearchTerm(query ?? "")
}, 400)
return () => clearTimeout(delayDebounceFn)
}, [query])
const { inventory_items, isLoading } = useAdminInventoryItems(
{
q: searchTerm,
limit: 7,
},
{
enabled: !!searchTerm,
}
)
const toggleInventoryItem = (item: InventoryItemDTO) => {
const newState = getNewSetState(selectedItems, item)
setSelectedItems(newState)
setFilter([...newState].map((i) => i.id))
}
const selectedIds = React.useMemo(() => {
return new Set([...selectedItems].map((i) => i.id))
}, [selectedItems])
const reset = () => {
setSelectedItems(new Set())
setFilter(undefined)
}
return (
<div className={clsx("w-full border-b")}>
<CollapsibleWrapper
title={title}
defaultOpen={!!value}
onOpenChange={(open) => {
if (!open) {
reset()
}
}}
>
<div className="gap-y-xsmall mb-2 flex w-full flex-col pt-2">
<InputField
value={query}
className="pr-1"
prefix={
selectedItems.size === 0 ? null : (
<div
onClick={reset}
className="bg-grey-10 border-grey-20 text-grey-40 rounded-rounded gap-x-2xsmall mr-xsmall flex cursor-pointer items-center border py-0.5 pr-1 pl-2"
>
<span className="text-grey-50">{selectedItems.size}</span>
<CrossIcon size={16} />
</div>
)
}
placeholder={selectedItems.size ? "Items selected" : "Find items"}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="flex flex-col gap-y-1 pb-2">
{[...selectedItems].map((item, i) => {
return (
<InventoryItemItem
key={`selected-item-${i}`}
onClick={() => toggleInventoryItem(item)}
selected={true}
item={item}
/>
)
})}
{searchTerm &&
(isLoading ? (
<Spinner />
) : (
<>
{inventory_items
?.filter((item) => !selectedIds.has(item.id))
.map((item: InventoryItemDTO, i: number) => (
<InventoryItemItem
key={`item-${i}`}
onClick={() => toggleInventoryItem(item)}
selected={false}
item={item}
/>
))}
</>
))}
</div>
</CollapsibleWrapper>
</div>
)
}
const InventoryItemItem = ({
key,
onClick,
selected,
item,
}: {
key: string
onClick: () => void
selected: boolean
item: InventoryItemDTO
}) => (
<div
key={key}
onClick={onClick}
className="hover:bg-grey-10 rounded-rounded flex items-center py-1.5 px-2"
>
<div className="mr-2 flex h-[20px] w-[20px] items-center">
{selected && <CheckIcon size={16} color="#111827" />}
</div>
<div className="inter-small-regular flex w-full items-center justify-between">
<p className="text-grey-90">{item.title}</p>{" "}
<p className="text-grey-50">{item.sku}</p>
</div>
</div>
)
const CreatedByFilterItem = ({
title,
setFilter,
value,
}: {
title: string
value: any
setFilter: (newFilter: any) => void
}) => {
const [selectedUsers, setSelectedUsers] = useState<Set<PasswordlessUser>>(
new Set()
)
const [searchTerm, setSearchTerm] = useState<string | undefined>()
const [query, setQuery] = useState<string | undefined>()
// Debounced search
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
setSearchTerm(query ?? "")
}, 400)
return () => clearTimeout(delayDebounceFn)
}, [query])
const { users, isLoading } = useAdminUsers({})
const [displayUsers, setDisplayUsers] = useState<PasswordlessUser[]>([])
useEffect(() => {
const getSelectedItems = async (value: any) => {
if (value && users) {
const selectedUsers = users.filter((u) => value.includes(u.id))
setSelectedUsers(new Set(selectedUsers))
}
}
getSelectedItems(value)
}, [value])
useEffect(() => {
if (searchTerm && users?.length) {
setDisplayUsers(
users
.filter(
(u) =>
u.first_name?.toLowerCase().includes(searchTerm) ||
u.last_name?.toLowerCase().includes(searchTerm)
)
.slice(0, 7)
)
}
}, [searchTerm, users])
const toggleUser = (user: PasswordlessUser) => {
const newState = getNewSetState(selectedUsers, user)
setSelectedUsers(newState)
setFilter([...newState].map((u) => u.id))
}
const reset = () => {
setSelectedUsers(new Set())
setFilter(undefined)
}
return (
<div className={clsx("w-full cursor-pointer")}>
<CollapsibleWrapper
title={title}
defaultOpen={!!value}
onOpenChange={(open) => {
if (!open) {
reset()
}
}}
>
<div className="gap-y-xsmall mb-2 flex w-full flex-col pt-2">
<InputField
value={query}
placeholder="Find user"
onChange={(e) => setQuery(e.target.value)}
prefix={
selectedUsers.size === 0 ? null : (
<div
onClick={reset}
className="bg-grey-10 border-grey-20 text-grey-40 rounded-rounded gap-x-2xsmall mr-xsmall flex cursor-pointer items-center border py-0.5 pr-1 pl-2"
>
<span className="text-grey-50">{selectedUsers.size}</span>
<CrossIcon size={16} />
</div>
)
}
/>
</div>
<div className="flex flex-col gap-y-1 pb-2">
{[...selectedUsers].map((user, i) => {
return (
<CreatedByItem
key={`selected-user-${i}`}
onClick={() => toggleUser(user)}
selected={true}
user={user}
/>
)
})}
{!isLoading && searchTerm && (
<>
{displayUsers
?.filter((user) => !selectedUsers.has(user))
.map((u, i) => (
<CreatedByItem
key={`user-${i}`}
onClick={() => toggleUser(u)}
selected={false}
user={u}
/>
))}
</>
)}
</div>
</CollapsibleWrapper>
</div>
)
}
const CreatedByItem = ({
key,
onClick,
selected,
user,
}: {
key: string
onClick: () => void
selected: boolean
user: PasswordlessUser
}) => {
return (
<div
key={key}
onClick={onClick}
className="hover:bg-grey-10 inter-small-regular rounded-rounded flex items-center py-1.5 px-2"
>
<div className="inter-small-regular mr-2 flex h-[20px] w-[20px] items-center">
{selected && <CheckIcon size={16} color="#111827" />}
</div>
<div>{`${user.first_name} ${user.last_name}`}</div>
</div>
)
}
const TextFilterItem = ({
title,
setFilter,
value,
options,
}: {
title: string
value: any
options: { value: string; label: string }[]
setFilter: (newFilter: any) => void
}) => {
const [fieldValue, setFieldValue] = useState(value)
const [filterType, setFilterType] = useState<{
value: string
label: string
}>(options.find((o) => !!value?.[o.value]) || options[0])
const selectFilterType = (newFilter: { value: string; label: string }) => {
const value = fieldValue?.[filterType.value]
setFilterType(newFilter)
setFieldValue({ [newFilter.value]: value })
setFilter({ [newFilter.value]: value })
}
const updateFieldValue = (val: string) => {
setFieldValue({ [filterType.value]: val })
setFilter({ [filterType.value]: val })
}
return (
<div className={clsx("w-full border-b")}>
<CollapsibleWrapper
title={title}
defaultOpen={!!value}
onOpenChange={(open) => {
if (!open) {
setFilter(undefined)
}
}}
>
<div className="gap-y-xsmall flex w-full flex-col py-2">
<PopoverSelect
options={options}
value={filterType}
onChange={selectFilterType}
/>
<InputField
value={fieldValue?.[filterType.value]}
placeholder="Write something"
onChange={(e) => updateFieldValue(e.target.value)}
/>
</div>
</CollapsibleWrapper>
</div>
)
}
const NumberFilterItem = ({
title,
setFilter,
value,
options,
}: {
title: string
value: any
options: { value: string; label: string }[]
setFilter: (newFilter: any) => void
}) => {
const getInitialValue = (value: any) => {
const keyLength = value ? Object.keys(value).length : 0
if (keyLength === 1) {
return value
} else if (keyLength === 2) {
return { gt: value.gt }
}
return null
}
const getInitialFilter = (value: any) => {
const keyLength = value ? Object.keys(value).length : 0
if (keyLength === 1) {
return (
options.find((o) => o.value === Object.keys(value)[0]) || options[0]
)
} else if (keyLength === 2) {
return { label: "Between", value: "between" }
}
return options[0]
}
const [fieldValue, setFieldValue] = useState(getInitialValue(value))
const [upperBound, setUpperBound] = useState<null | string>(
getInitialFilter(value).value === "between" ? value.lt : undefined
)
const [filterType, setFilterType] = useState<{
value: string
label: string
}>(getInitialFilter(value))
const selectFilterType = (newFilter: { value: string; label: string }) => {
const value = fieldValue?.[filterType.value]
switch (newFilter.value) {
case "lt":
case "gt":
setFieldValue({ [newFilter.value]: value })
setFilter({ [newFilter.value]: value })
break
case "between":
setFieldValue({ ["gt"]: value })
setFilter({ ["gt"]: value })
}
setFilterType(newFilter)
}
const updateFieldValue = (val: string) => {
switch (filterType.value) {
case "lt":
case "gt":
setFieldValue({ [filterType.value]: val })
setFilter({ [filterType.value]: val })
break
case "between":
setFieldValue({ ["gt"]: val })
setFilter({ ["gt"]: val })
}
}
const setUpperBoundValue = (val: string | null) => {
setUpperBound(val)
const value = fieldValue?.gt
setFilter({ gt: value, lt: val })
}
const getLowerBoundValue = () => {
if (filterType.value === "between") {
return fieldValue?.gt
}
return fieldValue?.[filterType.value]
}
const reset = () => {
setFilter(undefined)
setFieldValue(null)
setUpperBound(null)
}
return (
<div className={clsx("w-full border-b")}>
<CollapsibleWrapper
title={title}
defaultOpen={!!value}
onOpenChange={(open) => {
if (!open) {
reset()
}
}}
>
<div className="gap-y-xsmall flex w-full flex-col py-2">
<PopoverSelect
options={options}
value={filterType}
onChange={selectFilterType}
/>
<div className="flex items-center gap-x-2">
<InputField
value={getLowerBoundValue()}
placeholder="0"
type="number"
onChange={(e) => updateFieldValue(e.target.value)}
/>
{filterType.value === "between" && (
<>
<span>-</span>
<InputField
value={upperBound ?? undefined}
placeholder="0"
type="number"
onChange={(e) => setUpperBoundValue(e.target.value)}
/>
</>
)}
</div>
</div>
</CollapsibleWrapper>
</div>
)
}
const DateFilterItem = ({
title,
setFilter,
value,
}: {
title: string
value: any
setFilter: (newFilter: any) => void
}) => {
const options = [
{ label: "Before", value: "lt" },
{ label: "After", value: "gt" },
{ label: "Between", value: "between" },
]
const getInitialFilter = (value: DateComparisonOperator) => {
const keyLength = value ? Object.keys(value).length : 0
if (keyLength === 1) {
return (
options.find((o) => o.value === Object.keys(value)[0]) || options[0]
)
} else if (keyLength === 2) {
return { label: "Between", value: "between" }
}
return options[0]
}
const getDate1 = (value: DateComparisonOperator) => {
const initialFilter = getInitialFilter(value)
switch (initialFilter.value) {
case "lt":
case "gt":
return value?.[initialFilter.value] ?? null
case "between":
return value?.["gt"] ?? null
}
return null
}
const getDate2 = (value: DateComparisonOperator) => {
const initialFilter = getInitialFilter(value)
switch (initialFilter.value) {
case "between":
return value["lt"] ?? null
}
return null
}
const [filterType, setFilterType] = useState<{
value: string
label: string
}>(getInitialFilter(value))
const [date1, setDate1] = useState<Date | null>(getDate1(value))
const [date2, setDate2] = useState<Date | null>(getDate2(value))
const setFilterDate = (values: {
date1: Date | null
date2: Date | null
}) => {
const { date1, date2 } = values
setDate1(date1)
setDate2(date2)
switch (filterType.value) {
case "lt":
case "gt": {
if (date1) {
setFilter({
[filterType.value]: date1,
})
setDate2(null)
}
break
}
case "between":
if (date1 && date2) {
setFilter({
lt: date2,
gt: date1,
})
}
break
}
}
const updateFilterType = (newFilter: { value: string; label: string }) => {
switch (newFilter.value) {
case "lt":
case "gt":
if (date1) {
setFilter({
[newFilter.value]: date1,
})
setDate2(null)
}
break
case "between":
if (date1 && date2) {
setFilter({
lt: date2,
gt: date1,
})
}
break
}
setFilterType(newFilter)
}
return (
<div className={clsx("w-full border-b")}>
<CollapsibleWrapper
title={title}
defaultOpen={!!value}
onOpenChange={(open) => {
if (!open) {
setFilter(undefined)
}
}}
>
<div className="gap-y-xsmall flex w-full flex-col py-2">
<PopoverSelect
options={options}
value={filterType}
onChange={updateFilterType}
/>
<div className="flex items-center gap-x-2">
<FilterDatePicker
date={date1}
setDate={(date) => setFilterDate({ date1: date, date2 })}
/>
{filterType.value === "between" && (
<>
<span>-</span>
<FilterDatePicker
date={date2}
setDate={(date) => setFilterDate({ date1, date2: date })}
/>
</>
)}
</div>
</div>
</CollapsibleWrapper>
</div>
)
}
const FilterDatePicker = ({
date,
setDate,
side = "right",
}: {
date: Date | null
setDate: (date: Date | null) => void
side?: "left" | "right"
}) => (
<RadixPopover.Root>
<RadixPopover.Trigger className="w-full">
<div className="rounded-rounded inter-small-regular bg-grey-5 flex w-full items-center border py-1.5 pl-3 pr-2 text-left">
<span className="text-grey-40 mr-1.5">
<CalendarIcon size={20} />
</span>
{date ? (
<p className="mt-0.5">{moment(date).format("MMM DD, YYYY")}</p>
) : (
<p className="text-grey-40 mt-0.5">Pick a date</p>
)}
</div>
</RadixPopover.Trigger>
<RadixPopover.Content
side={side}
sideOffset={8}
className="bg-grey-0 rounded-rounded border p-1"
>
<CalendarComponent date={date} onChange={setDate} greyPastDates={false} />
</RadixPopover.Content>
</RadixPopover.Root>
)
const PopoverSelect = ({
value,
options,
onChange,
}: {
value: { value: string; label: string }
options: { value: string; label: string }[]
onChange: (value: { value: string; label: string }) => void
}) => {
return (
<RadixPopover.Root>
<RadixPopover.Trigger className="w-full">
<div className="rounded-rounded bg-grey-5 w-full border py-1.5 pl-3 pr-2 text-left">
{value.label}
</div>
</RadixPopover.Trigger>
<RadixPopover.Content
side="right"
align="start"
sideOffset={2}
className="bg-grey-0 rounded-rounded w-52 border p-1"
>
{options.map((o, i) => (
<div
key={i}
className="hover:bg-grey-5 rounded-rounded mb-1 flex py-1.5 px-2"
onClick={() => onChange(o)}
>
<div className="mr-2 h-[20px] w-[20px]">
{value.value === o.value && (
<TagDotIcon size={20} outerColor="#FFF" color="#111827" />
)}
</div>
<p>{o.label}</p>
</div>
))}
</RadixPopover.Content>
</RadixPopover.Root>
)
}
const CollapsibleWrapper = ({
onOpenChange,
defaultOpen,
title,
children,
}: {
onOpenChange: (boolean) => void
defaultOpen: boolean
title: string
} & React.HTMLAttributes<HTMLDivElement>) => {
const [isOpen, setIsOpen] = useState(defaultOpen)
return (
<div className={clsx("border-grey-5 w-full border-b")}>
<RadixCollapsible.Root
defaultOpen={defaultOpen}
onOpenChange={(open) => {
setIsOpen(open)
onOpenChange(open)
}}
className="w-full"
>
<RadixCollapsible.Trigger
className={clsx(
"text-grey-50 flex w-full cursor-pointer items-center justify-between rounded py-1.5 px-3"
)}
>
<p>{title}</p>
<Switch checked={isOpen} type="button" className="cursor-pointer" />
</RadixCollapsible.Trigger>
<RadixCollapsible.Content className="flex w-full flex-col gap-y-2 px-2">
{children}
</RadixCollapsible.Content>
</RadixCollapsible.Root>
</div>
)
}
function getNewSetState<T>(state: Set<T>, value: T) {
if (state.has(value)) {
state.delete(value)
return new Set(state)
}
return new Set(state.add(value))
}
export default ReservationsFilters
@@ -1,25 +1,32 @@
import * as RadixPopover from "@radix-ui/react-popover"
import { Cell, Row, TableRowProps, usePagination, useTable } from "react-table"
import React, { useEffect, useMemo, useState } from "react"
import React, { useEffect, useMemo, useRef, useState } from "react"
import { ReservationItemDTO, StockLocationDTO } from "@medusajs/types"
import {
useAdminDeleteReservation,
useAdminReservations,
useAdminStockLocations,
useAdminStore,
} from "medusa-react"
import BuildingsIcon from "../../fundamentals/icons/buildings-icon"
import Button from "../../fundamentals/button"
import DeletePrompt from "../../organisms/delete-prompt"
import EditIcon from "../../fundamentals/icons/edit-icon"
import EditReservationDrawer from "../../../domain/orders/details/reservation/edit-reservation-modal"
import Fade from "../../atoms/fade-wrapper"
import NewReservation from "./new"
import { NextSelect } from "../../molecules/select/next-select"
import { ReservationItemDTO } from "@medusajs/types"
import { Option } from "../../../types/shared"
import ReservationsFilters from "./components/reservations-filter"
import Table from "../../molecules/table"
import TableContainer from "../../../components/organisms/table-container"
import TagDotIcon from "../../fundamentals/icons/tag-dot-icon"
import Tooltip from "../../atoms/tooltip"
import TrashIcon from "../../fundamentals/icons/trash-icon"
import clsx from "clsx"
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"
@@ -36,38 +43,95 @@ const LocationDropdown = ({
selectedLocation: string
onChange: (id: string) => void
}) => {
const [open, setOpen] = useState(false)
const ref = useRef(null)
const { stock_locations: locations, isLoading } = useAdminStockLocations()
useEffect(() => {
if (!selectedLocation && !isLoading && locations?.length) {
onChange(locations[0].id)
const locationOptions = useMemo(() => {
let locationOptions: { label: string; value?: string }[] = []
if (!isLoading && locations) {
locationOptions = locations.map((l: StockLocationDTO) => ({
label: l.name,
value: l.id,
}))
}
}, [isLoading, locations, onChange, selectedLocation])
locationOptions.unshift({ label: "All locations", value: undefined })
return locationOptions
}, [isLoading, locations])
const selectedLocObj = useMemo(() => {
if (!isLoading && locations) {
return locations.find((l) => l.id === selectedLocation)
if (locationOptions?.length) {
return (
locationOptions.find(
(l: { value?: string; label: string }) => l.value === selectedLocation
) ?? locationOptions[0]
)
}
return null
}, [selectedLocation, locations, isLoading])
}, [selectedLocation, locationOptions])
if (isLoading || !locations || !selectedLocObj) {
const isEllipsisActive = (
e: { offsetWidth: number; scrollWidth: number } | null
) => {
if (!e) {
return false
}
return e.offsetWidth < e.scrollWidth
}
if (isLoading || !locationOptions?.length) {
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 className="max-w-[220px]">
<RadixPopover.Root open={open} onOpenChange={setOpen}>
<RadixPopover.Trigger className="w-full">
<Tooltip
className={clsx({ hidden: !isEllipsisActive(ref.current) })}
delayDuration={1000}
content={<div>{selectedLocObj?.label}</div>}
>
<Button
size="small"
variant="secondary"
spanClassName="flex grow"
className="max-w-[220px] items-center justify-start"
>
<BuildingsIcon size={20} />
<span ref={ref} className="max-w-[166px] truncate">
{selectedLocObj?.label}
</span>
</Button>
</Tooltip>
</RadixPopover.Trigger>
<RadixPopover.Content
side="bottom"
align="center"
sideOffset={2}
className="rounded-rounded z-50 w-[220px] border bg-white p-1"
>
{locationOptions.map((o, i) => (
<div
key={i}
className="hover:bg-grey-5 rounded-rounded mb-1 flex py-1.5 px-2"
onClick={() => {
setOpen(false)
onChange(o!.value)
}}
>
<div className="mr-2 h-[20px] w-[20px]">
{selectedLocObj?.value === o.value && (
<TagDotIcon size={20} outerColor="#FFF" color="#111827" />
)}
</div>
<p className="w-[166px]">{o.label}</p>
</div>
))}
</RadixPopover.Content>
</RadixPopover.Root>
</div>
)
}
@@ -98,6 +162,9 @@ const ReservationsTable: React.FC<ReservationsTableProps> = () => {
setQuery: setFreeText,
queryObject,
representationObject,
filters,
setFilters,
setDefaultFilters,
} = useReservationFilters(location.search, defaultQuery)
const offs = parseInt(queryObject.offset) || 0
@@ -222,16 +289,18 @@ const ReservationsTable: React.FC<ReservationsTableProps> = () => {
isLoading={isLoading}
>
<Table
enableSearch
searchClassName="h-[40px]"
handleSearch={setQuery}
searchValue={query}
tableActions={
<div className="flex gap-2">
<ReservationsFilters
submitFilters={setFilters}
clearFilters={setDefaultFilters}
filters={filters}
/>
<LocationDropdown
selectedLocation={
queryObject.location_id || store?.default_location_id
}
selectedLocation={queryObject.location_id}
onChange={(id) => {
setLocationFilter(id)
gotoPage(0)
@@ -46,7 +46,7 @@ const NewReservation = ({
const notification = useNotification()
const onSubmit = async (data) => {
const onSubmit = async (data: NewReservationFormType) => {
const payload = await createPayload(data)
createReservation(payload, {
@@ -54,7 +54,7 @@ const NewReservation = ({
notification("Success", "Successfully created reservation", "success")
onClose()
},
onError: (err) => {
onError: (err: Error) => {
notification("Error", getErrorMessage(err), "error")
},
})
@@ -111,8 +111,8 @@ const createPayload = (
data: NewReservationFormType
): AdminPostReservationsReq => {
return {
location_id: data.general.location,
inventory_item_id: data.general.item.id!,
location_id: data.general.location!,
inventory_item_id: data.general.item!.id!,
quantity: data.general.quantity,
description: data.general.description,
metadata: getSubmittableMetadata(data.metadata),
@@ -1,6 +1,10 @@
import { useMemo, useReducer, useState } from "react"
import {
DateComparisonOperator,
NumericalComparisonOperator,
StringComparisonOperator,
} from "@medusajs/types"
import { useMemo, useReducer } from "react"
import { omit } from "lodash"
import qs from "qs"
import { relativeDateFormatToTimestamp } from "../../../utils/time"
@@ -23,12 +27,46 @@ interface ReservationFilterState {
limit: number
offset: number
location: string
additionalFilters: ReservationDefaultFilters | null
additionalFilters: ReservationAdditionalFilters
}
const allowedFilters = ["q", "offset", "limit", "location_id"]
type ReservationAdditionalFilters = {
quantity?: NumericalComparisonOperator
inventory_item_id?: string[]
created_at?: DateComparisonOperator
created_by?: string[]
location_id?: string
description?: string | StringComparisonOperator
}
const DefaultTabs = {}
type ReservationDefaultFilters = {
expand?: string
fields?: string
location_id?: string
}
const allowedFilters = [
"description",
"offset",
"limit",
"location_id",
"inventory_item_id",
"quantity",
"created_at",
"created_by",
]
const stateFilterMap = {
location: "location_id",
inventory_item: "inventory_item_id",
created_at: "created_at",
date: "created_at",
created_by: "created_by",
description: "description",
query: "q",
offset: "offset",
limit: "limit",
}
const formatDateFilter = (filter: ReservationDateFilter) => {
if (filter === null) {
@@ -55,7 +93,7 @@ const reducer = (
case "setFilters": {
return {
...state,
query: action?.payload?.query,
...action.payload,
}
}
case "setQuery": {
@@ -64,6 +102,12 @@ const reducer = (
query: action.payload,
}
}
case "setDefaults": {
return {
...state,
additionalFilters: {},
}
}
case "setLimit": {
return {
...state,
@@ -91,27 +135,9 @@ const reducer = (
}
}
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
defaultFilters: ReservationDefaultFilters = {}
) => {
if (existing && existing[0] === "?") {
existing = existing.substring(1)
@@ -122,37 +148,12 @@ export const useReservationFilters = (
[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 })
@@ -190,6 +191,11 @@ export const useReservationFilters = (
const getQueryObject = () => {
const toQuery: any = { ...state.additionalFilters }
if (typeof toQuery.description?.["equals"] === "string") {
toQuery.description = toQuery.description["equals"]
}
for (const [key, value] of Object.entries(state)) {
if (key === "query") {
if (value && typeof value === "string") {
@@ -197,7 +203,7 @@ export const useReservationFilters = (
}
} else if (key === "offset" || key === "limit") {
toQuery[key] = value
} else if (value.open) {
} else if (value?.open) {
if (key === "date") {
toQuery[stateFilterMap[key]] = formatDateFilter(
value.filter as ReservationDateFilter
@@ -206,7 +212,11 @@ export const useReservationFilters = (
toQuery[stateFilterMap[key]] = value.filter
}
} else if (key === "location") {
toQuery[stateFilterMap[key]] = value
if (value) {
toQuery[stateFilterMap[key]] = value
} else {
delete toQuery[stateFilterMap[key]]
}
}
}
@@ -215,212 +225,48 @@ export const useReservationFilters = (
return toQuery
}
const getQueryString = () => {
const obj = getQueryObject()
return qs.stringify(obj, { skipNulls: true })
}
const getRepresentationObject = (fromObject?: ReservationFilterState) => {
const objToUse = fromObject ?? state
const { additionalFilters, ...filters } = objToUse
const entryObje = { ...additionalFilters, ...filters }
const toQuery: any = {}
for (const [key, value] of Object.entries(objToUse)) {
for (const [key, value] of Object.entries(entryObje)) {
if (key === "query") {
if (value && typeof value === "string") {
toQuery["q"] = value
}
} else if (key === "offset" || key === "limit" || key === "location") {
} else if (value) {
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
additionals: ReservationAdditionalFilters = {}
): ReservationFilterState => {
const defaultVal: ReservationFilterState = {
location: additionals?.location_id ?? "",
@@ -432,7 +278,7 @@ const parseQueryString = (
if (queryString) {
const filters = qs.parse(queryString)
for (const [key, value] of Object.entries(filters)) {
if (allowedFilters.includes(key)) {
if (allowedFilters.includes(key) && !!value) {
switch (key) {
case "offset": {
if (typeof value === "string") {
@@ -452,10 +298,25 @@ const parseQueryString = (
}
break
}
case "q": {
if (typeof value === "string") {
defaultVal.query = value
}
case "description": {
defaultVal.additionalFilters.description = value as string
break
}
case "quantity": {
defaultVal.additionalFilters.quantity =
value as NumericalComparisonOperator
break
}
case "inventory_item_id":
case "created_by": {
defaultVal.additionalFilters[key] = (
Array.isArray(value) ? value : [value]
) as string[]
break
}
case "created_at": {
defaultVal.additionalFilters.created_at =
value as DateComparisonOperator
break
}
default: {