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