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

View File

@@ -1,24 +1,37 @@
import * as PopoverPrimitive from "@radix-ui/react-popover"
import clsx from "clsx"
import moment from "moment"
import React, { useEffect, useState } from "react"
import ReactDatePicker from "react-datepicker"
import "react-datepicker/dist/react-datepicker.css"
import Button from "../../fundamentals/button"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import React, { useEffect, useState } from "react"
import ArrowDownIcon from "../../fundamentals/icons/arrow-down-icon"
import InputContainer from "../../fundamentals/input-container"
import InputHeader from "../../fundamentals/input-header"
import Button from "../../fundamentals/button"
import CustomHeader from "./custom-header"
import { DateTimePickerProps } from "./types"
import InputContainer from "../../fundamentals/input-container"
import InputHeader from "../../fundamentals/input-header"
import ReactDatePicker from "react-datepicker"
import clsx from "clsx"
import moment from "moment"
const getDateClassname = (d: Date, tempDate: Date) => {
return moment(d).format("YY,MM,DD") === moment(tempDate).format("YY,MM,DD")
? "date chosen"
: `date ${
moment(d).format("YY,MM,DD") < moment(new Date()).format("YY,MM,DD")
? "past"
: ""
}`
const getDateClassname = (
d: Date,
tempDate: Date | null,
greyPastDates: boolean = true
): string => {
const classes: string[] = ["date"]
if (
tempDate &&
moment(d).format("YY,MM,DD") === moment(tempDate).format("YY,MM,DD")
) {
classes.push("chosen")
} else if (
greyPastDates &&
moment(d).format("YY,MM,DD") < moment(new Date()).format("YY,MM,DD")
) {
classes.push("past")
}
return classes.join(" ")
}
const DatePicker: React.FC<DateTimePickerProps> = ({
@@ -64,14 +77,16 @@ const DatePicker: React.FC<DateTimePickerProps> = ({
>
<InputContainer className="shadown-none border-0 focus-within:shadow-none">
<div className="text-grey-50 flex w-full justify-between pr-0.5">
<InputHeader
{...{
label,
required,
tooltipContent,
tooltip,
}}
/>
{label && (
<InputHeader
{...{
label,
required,
tooltipContent,
tooltip,
}}
/>
)}
<ArrowDownIcon size={16} />
</div>
<label className="w-full text-left">
@@ -119,18 +134,20 @@ type CalendarComponentProps = {
date: Date | null,
event: React.SyntheticEvent<any, Event> | undefined
) => void
greyPastDates?: boolean
}
export const CalendarComponent = ({
date,
onChange,
greyPastDates = true,
}: CalendarComponentProps) => (
<ReactDatePicker
selected={date}
inline
onChange={onChange}
calendarClassName="date-picker"
dayClassName={(d) => getDateClassname(d, date)}
dayClassName={(d) => getDateClassname(d, date, greyPastDates)}
renderCustomHeader={({ ...props }) => <CustomHeader {...props} />}
/>
)

View File

@@ -1,6 +1,7 @@
import * as RadixTooltip from "@radix-ui/react-tooltip"
import clsx from "clsx"
import React from "react"
import clsx from "clsx"
export type TooltipProps = RadixTooltip.TooltipContentProps &
Pick<

View File

@@ -1,11 +1,13 @@
import clsx from "clsx"
import React, { Children } from "react"
import Spinner from "../../atoms/spinner"
import clsx from "clsx"
export type ButtonProps = {
variant: "primary" | "secondary" | "ghost" | "danger" | "nuclear"
size?: "small" | "medium" | "large"
loading?: boolean
spanClassName?: string
} & React.ButtonHTMLAttributes<HTMLButtonElement>
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@@ -14,6 +16,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
variant = "primary",
size = "large",
loading = false,
spanClassName,
children,
...attributes
},
@@ -57,7 +60,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
) : (
Children.map(children, (child, i) => {
return (
<span key={i} className="mr-xsmall last:mr-0">
<span
key={i}
className={clsx("mr-xsmall last:mr-0", spanClassName)}
>
{child}
</span>
)

View File

@@ -0,0 +1,29 @@
import React from "react"
import IconProps from "./types/icon-type"
const AdjustmentsIcon: React.FC<IconProps> = ({
size = "24px",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
d="M8.75 5H16.875M8.75 5C8.75 5.33152 8.6183 5.64946 8.38388 5.88388C8.14946 6.1183 7.83152 6.25 7.5 6.25C7.16848 6.25 6.85054 6.1183 6.61612 5.88388C6.3817 5.64946 6.25 5.33152 6.25 5M8.75 5C8.75 4.66848 8.6183 4.35054 8.38388 4.11612C8.14946 3.8817 7.83152 3.75 7.5 3.75C7.16848 3.75 6.85054 3.8817 6.61612 4.11612C6.3817 4.35054 6.25 4.66848 6.25 5M3.125 5H6.25M8.75 15H16.875M8.75 15C8.75 15.3315 8.6183 15.6495 8.38388 15.8839C8.14946 16.1183 7.83152 16.25 7.5 16.25C7.16848 16.25 6.85054 16.1183 6.61612 15.8839C6.3817 15.6495 6.25 15.3315 6.25 15M8.75 15C8.75 14.6685 8.6183 14.3505 8.38388 14.1161C8.14946 13.8817 7.83152 13.75 7.5 13.75C7.16848 13.75 6.85054 13.8817 6.61612 14.1161C6.3817 14.3505 6.25 14.6685 6.25 15M3.125 15H6.25M13.75 10H16.875M13.75 10C13.75 10.3315 13.6183 10.6495 13.3839 10.8839C13.1495 11.1183 12.8315 11.25 12.5 11.25C12.1685 11.25 11.8505 11.1183 11.6161 10.8839C11.3817 10.6495 11.25 10.3315 11.25 10M13.75 10C13.75 9.66848 13.6183 9.35054 13.3839 9.11612C13.1495 8.8817 12.8315 8.75 12.5 8.75C12.1685 8.75 11.8505 8.8817 11.6161 9.11612C11.3817 9.35054 11.25 9.66848 11.25 10M3.125 10H11.25"
stroke={color}
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default AdjustmentsIcon

View File

@@ -0,0 +1,54 @@
import React from "react"
import IconProps from "./types/icon-type"
const CalendarIcon: React.FC<IconProps> = ({
size = "24px",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
d="M6.5 4V5.5M13.5 4V5.5M4 14.5V7C4 6.60218 4.15804 6.22064 4.43934 5.93934C4.72064 5.65804 5.10218 5.5 5.5 5.5H14.5C14.8978 5.5 15.2794 5.65804 15.5607 5.93934C15.842 6.22064 16 6.60218 16 7V14.5M4 14.5C4 14.8978 4.15804 15.2794 4.43934 15.5607C4.72064 15.842 5.10218 16 5.5 16H14.5C14.8978 16 15.2794 15.842 15.5607 15.5607C15.842 15.2794 16 14.8978 16 14.5M4 14.5V9.5C4 9.10218 4.15804 8.72064 4.43934 8.43934C4.72064 8.15804 5.10218 8 5.5 8H14.5C14.8978 8 15.2794 8.15804 15.5607 8.43934C15.842 8.72064 16 9.10218 16 9.5V14.5"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.875 10.8672C7.875 11.0743 7.70711 11.2422 7.5 11.2422C7.29289 11.2422 7.125 11.0743 7.125 10.8672C7.125 10.6601 7.29289 10.4922 7.5 10.4922C7.70711 10.4922 7.875 10.6601 7.875 10.8672Z"
stroke={color}
strokeWidth="0.75"
/>
<path
d="M7.875 13.1943C7.875 13.4014 7.70711 13.5693 7.5 13.5693C7.29289 13.5693 7.125 13.4014 7.125 13.1943C7.125 12.9872 7.29289 12.8193 7.5 12.8193C7.70711 12.8193 7.875 12.9872 7.875 13.1943Z"
stroke={color}
strokeWidth="0.75"
/>
<path
d="M12.875 10.8672C12.875 11.0743 12.7071 11.2422 12.5 11.2422C12.2929 11.2422 12.125 11.0743 12.125 10.8672C12.125 10.6601 12.2929 10.4922 12.5 10.4922C12.7071 10.4922 12.875 10.6601 12.875 10.8672Z"
stroke={color}
strokeWidth="0.75"
/>
<path
d="M10.375 10.8672C10.375 11.0743 10.2071 11.2422 10 11.2422C9.79289 11.2422 9.625 11.0743 9.625 10.8672C9.625 10.6601 9.79289 10.4922 10 10.4922C10.2071 10.4922 10.375 10.6601 10.375 10.8672Z"
stroke={color}
strokeWidth="0.75"
/>
<path
d="M10.375 13.1943C10.375 13.4014 10.2071 13.5693 10 13.5693C9.79289 13.5693 9.625 13.4014 9.625 13.1943C9.625 12.9872 9.79289 12.8193 10 12.8193C10.2071 12.8193 10.375 12.9872 10.375 13.1943Z"
stroke={color}
strokeWidth="0.75"
/>
</svg>
)
}
export default CalendarIcon

View File

@@ -1,4 +1,5 @@
import * as RadixPopover from "@radix-ui/react-popover"
import React, {
PropsWithChildren,
ReactNode,
@@ -6,8 +7,9 @@ import React, {
useRef,
useState,
} from "react"
import { useWindowDimensions } from "../../../hooks/use-window-dimensions"
import Button from "../../fundamentals/button"
import { useWindowDimensions } from "../../../hooks/use-window-dimensions"
type FilterDropdownContainerProps = {
submitFilters: () => void
@@ -52,13 +54,25 @@ const FilterDropdownContainer = ({
<RadixPopover.Content
sideOffset={8}
style={heightStyle}
className="bg-grey-0 rounded-rounded shadow-dropdown z-40 max-w-[272px] overflow-y-auto py-4"
className="bg-grey-0 rounded-rounded shadow-dropdown z-40 max-w-[320px] overflow-y-auto pt-1"
>
<div className="border-grey-20 flex border-b px-4 pb-4">
{React.Children.toArray(children)
.filter(Boolean)
.map((child, idx) => {
return (
<div
key={idx}
className="border-grey-20 border-b last:border-0 last:pb-0"
>
{child}
</div>
)
})}
<div className="border-grey-20 gap-x-small flex grid grid-cols-2 border-b px-3 py-2.5">
<Button
size="small"
tabIndex={-1}
className="border-grey-20 mr-2 border"
className="border-grey-20 mr-2 w-full border"
variant="ghost"
onClick={() => onClear()}
>
@@ -67,20 +81,13 @@ const FilterDropdownContainer = ({
<Button
tabIndex={-1}
variant="primary"
className="w-44 justify-center"
className="w-full justify-center"
size="small"
onClick={() => onSubmit()}
>
Apply
</Button>
</div>
{React.Children.toArray(children).filter(Boolean).map((child) => {
return (
<div className="border-grey-20 border-b py-2 px-4 last:border-0 last:pb-0">
{child}
</div>
)
})}
</RadixPopover.Content>
</RadixPopover.Root>
)

View File

@@ -1,16 +1,18 @@
import * as RadixCollapsible from "@radix-ui/react-collapsible"
import * as RadixPopover from "@radix-ui/react-popover"
import clsx from "clsx"
import moment from "moment"
import { useEffect, useMemo, useState } from "react"
import { DateFilters } from "../../../utils/filters"
import { addHours, atMidnight, dateToUnixTimestamp } from "../../../utils/time"
import { CalendarComponent } from "../../atoms/date-picker/date-picker"
import Spinner from "../../atoms/spinner"
import { useEffect, useMemo, useState } from "react"
import ArrowRightIcon from "../../fundamentals/icons/arrow-right-icon"
import { CalendarComponent } from "../../atoms/date-picker/date-picker"
import CheckIcon from "../../fundamentals/icons/check-icon"
import ChevronUpIcon from "../../fundamentals/icons/chevron-up"
import { DateFilters } from "../../../utils/filters"
import InputField from "../input"
import Spinner from "../../atoms/spinner"
import clsx from "clsx"
import moment from "moment"
const DAY_IN_SECONDS = 86400
@@ -84,7 +86,7 @@ const FilterDropdownItem = ({
return (
<div
className={clsx("w-full cursor-pointer", {
className={clsx("w-full cursor-pointer py-2 px-4 ", {
"inter-small-semibold": open,
"inter-small-regular": !open,
})}

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo } from "react"
import { useAdminStockLocations } from "medusa-react"
import { NextSelect } from "../select/next-select"
import { StockLocationDTO } from "@medusajs/types"
import { useAdminStockLocations } from "medusa-react"
const LocationDropdown = ({
selectedLocation,
@@ -19,7 +21,10 @@ const LocationDropdown = ({
const selectedLocObj = useMemo(() => {
if (!isLoading && locations) {
return locations.find((l) => l.id === selectedLocation) ?? locations[0]
return (
locations.find((l: StockLocationDTO) => l.id === selectedLocation) ??
locations[0]
)
}
}, [selectedLocation, locations, isLoading])
@@ -33,7 +38,7 @@ const LocationDropdown = ({
onChange={(loc) => {
onChange(loc!.value)
}}
options={locations.map((l) => ({
options={locations.map((l: StockLocationDTO) => ({
label: l.name,
value: l.id,
}))}

View File

@@ -1,5 +1,3 @@
import clsx from "clsx"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
ActionMeta,
CX,
@@ -12,11 +10,14 @@ import {
OptionsOrGroups,
PropsValue,
} from "react-select"
import { hasPrefix, hasSuffix, optionIsDisabled } from "../utils"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import Button from "../../../../fundamentals/button"
import CheckIcon from "../../../../fundamentals/icons/check-icon"
import ListArrowIcon from "../../../../fundamentals/icons/list-arrow-icon"
import { hasPrefix, hasSuffix, optionIsDisabled } from "../utils"
import SelectPrimitives from "./select-primitives"
import clsx from "clsx"
const Menu = <
Option,
@@ -279,7 +280,7 @@ export const Option = <
},
{
"h-xlarge": size === "sm",
"h-10": size === "md" || !size,
"min-h-10": size === "md" || !size,
},
className
)

View File

@@ -1,6 +1,9 @@
import clsx from "clsx"
import { GroupBase, SingleValueProps } from "react-select"
import Tooltip from "../../../../atoms/tooltip"
import clsx from "clsx"
import { hasPrefix } from "../utils"
import { useRef } from "react"
const SingleValue = <
Option,
@@ -13,28 +16,58 @@ const SingleValue = <
className,
isDisabled,
data,
getValue,
}: SingleValueProps<Option, IsMulti, Group>) => {
const prefix = hasPrefix(data) ? data.prefix : null
const isEllipsisActive = (e: HTMLDivElement | null) => {
if (!e || !(e.offsetParent as HTMLDivElement)?.offsetWidth) {
return false
}
return (e.offsetParent as HTMLDivElement).offsetWidth < e.scrollWidth
}
const getToolTipValue = () => {
const values = getValue()
if (!values.length) {
return null
}
const value = values[0] as { label: string } // option with label
return value.label ?? null
}
const toolTip = getToolTipValue()
const ref = useRef(null)
return (
<div
{...innerProps}
className={cx(
{
"single-value": true,
"single-value--is-disabled": isDisabled,
},
clsx(
"absolute top-1/2 -translate-y-1/2 overflow-hidden overflow-ellipsis whitespace-nowrap",
className
)
)}
<Tooltip
className={clsx({ hidden: !isEllipsisActive(ref.current) || !toolTip })}
delayDuration={1000}
content={<div>{toolTip}</div>}
>
<div className="gap-x-xsmall inter-base-regular flex items-center">
{prefix && <span className="inter-base-semibold">{prefix}</span>}
{children}
<div
{...innerProps}
ref={ref}
className={cx(
{
"single-value": true,
"single-value--is-disabled": isDisabled,
},
clsx(
"absolute top-1/2 -translate-y-1/2 overflow-hidden overflow-ellipsis whitespace-nowrap",
className
)
)}
>
<div className="gap-x-xsmall inter-base-regular flex items-center">
{prefix && <span className="inter-base-semibold">{prefix}</span>}
{children}
</div>
</div>
</div>
</Tooltip>
)
}

View File

@@ -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>
)
},
},
],
[]

View File

@@ -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}
/>
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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),

View File

@@ -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: {