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:
+52
-19
@@ -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>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[]
|
||||
|
||||
+13
-6
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
+906
@@ -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),
|
||||
|
||||
+88
-227
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user