feat(dashboard): Reservations and inventory item list pages (#6550)
**What** - Adds list page for reservations and inventory items - Adds new String filter type, that allows users to filter by a string, eg. "material === 'metal'" - Adds new Number filter type, that allows users to filter by a number or numerical comparator, eg. quantity === 10 / quantity is gt 10 and lt 50.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from "./inline-link"
|
||||
@@ -0,0 +1,21 @@
|
||||
import { clx } from "@medusajs/ui"
|
||||
import { ComponentPropsWithoutRef } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export const InlineLink = ({
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<typeof Link>) => {
|
||||
return (
|
||||
<Link
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
className={clx(
|
||||
"text-ui-fg-interactive transition-fg hover:text-ui-fg-interactive-hover focus-visible:text-ui-fg-interactive-hover rounded-md outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Buildings,
|
||||
ChevronDownMini,
|
||||
CurrencyDollar,
|
||||
MinusMini,
|
||||
@@ -61,14 +62,14 @@ const Header = () => {
|
||||
{fallback ? (
|
||||
<Avatar variant="squared" fallback={fallback} />
|
||||
) : (
|
||||
<Skeleton className="w-8 h-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
)}
|
||||
{name ? (
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{store.name}
|
||||
</Text>
|
||||
) : (
|
||||
<Skeleton className="w-[120px] h-[9px]" />
|
||||
<Skeleton className="h-[9px] w-[120px]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,9 +109,16 @@ const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
|
||||
label: t("giftCards.domain"),
|
||||
to: "/gift-cards",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <Buildings />,
|
||||
label: t("inventory.domain"),
|
||||
to: "/inventory",
|
||||
items: [
|
||||
{
|
||||
label: t("inventory.domain"),
|
||||
to: "/inventory",
|
||||
label: t("reservations.domain"),
|
||||
to: "/reservations",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+56
-24
@@ -3,9 +3,12 @@ import * as Popover from "@radix-ui/react-popover"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { DataTableFilterContext, useDataTableFilterContext } from "./context"
|
||||
import { DateFilter } from "./date-filter"
|
||||
import { NumberFilter } from "./number-filter"
|
||||
import { SelectFilter } from "./select-filter"
|
||||
import { StringFilter } from "./string-filter"
|
||||
|
||||
type Option = {
|
||||
label: string
|
||||
@@ -26,6 +29,14 @@ export type Filter = {
|
||||
type: "date"
|
||||
options?: never
|
||||
}
|
||||
| {
|
||||
type: "string"
|
||||
options?: never
|
||||
}
|
||||
| {
|
||||
type: "number"
|
||||
options?: never
|
||||
}
|
||||
)
|
||||
|
||||
type DataTableFilterProps = {
|
||||
@@ -34,6 +45,7 @@ type DataTableFilterProps = {
|
||||
}
|
||||
|
||||
export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -60,7 +72,6 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
||||
const key = prefix ? `${prefix}_${filter.key}` : filter.key
|
||||
const value = params.get(key)
|
||||
if (value && !activeFilters.find((af) => af.key === filter.key)) {
|
||||
console.log("adding filter", filter.key, "to active filters")
|
||||
if (filter.type === "select") {
|
||||
setActiveFilters((prev) => [
|
||||
...prev,
|
||||
@@ -109,40 +120,61 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
||||
>
|
||||
<div className="max-w-2/3 flex flex-wrap items-center gap-2">
|
||||
{activeFilters.map((filter) => {
|
||||
if (filter.type === "select") {
|
||||
return (
|
||||
<SelectFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
options={filter.options}
|
||||
multiple={filter.multiple}
|
||||
searchable={filter.searchable}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
switch (filter.type) {
|
||||
case "select":
|
||||
return (
|
||||
<SelectFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
options={filter.options}
|
||||
multiple={filter.multiple}
|
||||
searchable={filter.searchable}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
case "date":
|
||||
return (
|
||||
<DateFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
case "string":
|
||||
return (
|
||||
<StringFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
case "number":
|
||||
return (
|
||||
<NumberFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return (
|
||||
<DateFilter
|
||||
key={filter.key}
|
||||
filter={filter}
|
||||
prefix={prefix}
|
||||
openOnMount={filter.openOnMount}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{availableFilters.length > 0 && (
|
||||
<Popover.Root modal open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild id="filters_menu_trigger">
|
||||
<Button size="small" variant="secondary">
|
||||
Add filter
|
||||
{t("filters.addFilter")}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className={clx(
|
||||
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-hidden rounded-lg p-1 outline-none"
|
||||
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-auto rounded-lg p-1 outline-none"
|
||||
)}
|
||||
data-name="filters_menu_content"
|
||||
align="start"
|
||||
|
||||
+68
-35
@@ -3,8 +3,10 @@ import { DatePicker, Text, clx } from "@medusajs/ui"
|
||||
import * as Popover from "@radix-ui/react-popover"
|
||||
import { format } from "date-fns"
|
||||
import isEqual from "lodash/isEqual"
|
||||
import { MouseEvent, useState } from "react"
|
||||
import { MouseEvent, useMemo, useState } from "react"
|
||||
|
||||
import { t } from "i18next"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSelectedParams } from "../hooks"
|
||||
import { useDataTableFilterContext } from "./context"
|
||||
import { IFilter } from "./types"
|
||||
@@ -12,9 +14,21 @@ import { IFilter } from "./types"
|
||||
type DateFilterProps = IFilter
|
||||
|
||||
type DateComparisonOperator = {
|
||||
/**
|
||||
* The filtered date must be greater than or equal to this value.
|
||||
*/
|
||||
gte?: string
|
||||
/**
|
||||
* The filtered date must be less than or equal to this value.
|
||||
*/
|
||||
lte?: string
|
||||
/**
|
||||
* The filtered date must be less than this value.
|
||||
*/
|
||||
lt?: string
|
||||
/**
|
||||
* The filtered date must be greater than this value.
|
||||
*/
|
||||
gt?: string
|
||||
}
|
||||
|
||||
@@ -25,10 +39,14 @@ export const DateFilter = ({
|
||||
}: DateFilterProps) => {
|
||||
const [open, setOpen] = useState(openOnMount)
|
||||
const [showCustom, setShowCustom] = useState(false)
|
||||
|
||||
const { key, label } = filter
|
||||
|
||||
const { removeFilter } = useDataTableFilterContext()
|
||||
const selectedParams = useSelectedParams({ param: key, prefix })
|
||||
|
||||
const presets = usePresets()
|
||||
|
||||
const handleSelectPreset = (value: DateComparisonOperator) => {
|
||||
selectedParams.add(JSON.stringify(value))
|
||||
setShowCustom(false)
|
||||
@@ -100,7 +118,7 @@ export const DateFilter = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||
<DateDisplay label={label} value={displayValue} onRemove={handleRemove} />
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
@@ -167,7 +185,7 @@ export const DateFilter = ({
|
||||
>
|
||||
<EllipseMiniSolid />
|
||||
</div>
|
||||
Custom
|
||||
{t("filters.date.custom")}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -275,38 +293,53 @@ const DateDisplay = ({ label, value, onRemove }: DateDisplayProps) => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const presets: { label: string; value: DateComparisonOperator }[] = [
|
||||
{
|
||||
label: "Today",
|
||||
value: {
|
||||
gte: today.toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
value: {
|
||||
gte: new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
value: {
|
||||
gte: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Last 90 days",
|
||||
value: {
|
||||
gte: new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(), // 90 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Last 12 months",
|
||||
value: {
|
||||
gte: new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(), // 365 days ago
|
||||
},
|
||||
},
|
||||
]
|
||||
const usePresets = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("filters.date.today"),
|
||||
value: {
|
||||
gte: today.toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("filters.date.lastSevenDays"),
|
||||
value: {
|
||||
gte: new Date(
|
||||
today.getTime() - 7 * 24 * 60 * 60 * 1000
|
||||
).toISOString(), // 7 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("filters.date.lastThirtyDays"),
|
||||
value: {
|
||||
gte: new Date(
|
||||
today.getTime() - 30 * 24 * 60 * 60 * 1000
|
||||
).toISOString(), // 30 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("filters.date.lastNinetyDays"),
|
||||
value: {
|
||||
gte: new Date(
|
||||
today.getTime() - 90 * 24 * 60 * 60 * 1000
|
||||
).toISOString(), // 90 days ago
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("filters.date.lastTwelveMonths"),
|
||||
value: {
|
||||
gte: new Date(
|
||||
today.getTime() - 365 * 24 * 60 * 60 * 1000
|
||||
).toISOString(), // 365 days ago
|
||||
},
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
|
||||
const parseDateComparison = (value: string[]) => {
|
||||
return value?.length
|
||||
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
import { EllipseMiniSolid, XMarkMini } from "@medusajs/icons"
|
||||
import { Input, Label, Text, clx } from "@medusajs/ui"
|
||||
import * as Popover from "@radix-ui/react-popover"
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group"
|
||||
import { debounce } from "lodash"
|
||||
import {
|
||||
ChangeEvent,
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useSelectedParams } from "../hooks"
|
||||
import { useDataTableFilterContext } from "./context"
|
||||
import { IFilter } from "./types"
|
||||
|
||||
type NumberFilterProps = IFilter
|
||||
|
||||
type Comparison = "exact" | "range"
|
||||
type Operator = "lt" | "gt" | "eq"
|
||||
|
||||
export const NumberFilter = ({
|
||||
filter,
|
||||
prefix,
|
||||
openOnMount,
|
||||
}: NumberFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(openOnMount)
|
||||
|
||||
const { key, label } = filter
|
||||
|
||||
const { removeFilter } = useDataTableFilterContext()
|
||||
const selectedParams = useSelectedParams({
|
||||
param: key,
|
||||
prefix,
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
const currentValue = selectedParams.get()
|
||||
|
||||
const [operator, setOperator] = useState<Comparison | undefined>(
|
||||
getOperator(currentValue)
|
||||
)
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedOnChange = useCallback(
|
||||
debounce((e: ChangeEvent<HTMLInputElement>, operator: Operator) => {
|
||||
const value = e.target.value
|
||||
const curr = JSON.parse(currentValue?.join(",") || "{}")
|
||||
const isCurrentNumber = !isNaN(Number(curr))
|
||||
|
||||
const handleValue = (operator: Operator) => {
|
||||
if (!value && isCurrentNumber) {
|
||||
selectedParams.delete()
|
||||
return
|
||||
}
|
||||
|
||||
if (curr && !value) {
|
||||
delete curr[operator]
|
||||
selectedParams.add(JSON.stringify(curr))
|
||||
return
|
||||
}
|
||||
|
||||
if (!curr) {
|
||||
selectedParams.add(JSON.stringify({ [operator]: value }))
|
||||
return
|
||||
}
|
||||
|
||||
selectedParams.add(JSON.stringify({ ...curr, [operator]: value }))
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
if (!value) {
|
||||
selectedParams.delete()
|
||||
} else {
|
||||
selectedParams.add(value)
|
||||
}
|
||||
break
|
||||
case "lt":
|
||||
case "gt":
|
||||
handleValue(operator)
|
||||
break
|
||||
}
|
||||
}, 500),
|
||||
[selectedParams, currentValue]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedOnChange.cancel()
|
||||
}
|
||||
}, [debouncedOnChange])
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (!open && !currentValue.length) {
|
||||
timeoutId = setTimeout(() => {
|
||||
removeFilter(key)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
selectedParams.delete()
|
||||
removeFilter(key)
|
||||
}
|
||||
|
||||
const operators: { operator: Comparison; label: string }[] = [
|
||||
{
|
||||
operator: "exact",
|
||||
label: t("filters.compare.exact"),
|
||||
},
|
||||
{
|
||||
operator: "range",
|
||||
label: t("filters.compare.range"),
|
||||
},
|
||||
]
|
||||
|
||||
const GT_KEY = `${key}-gt`
|
||||
const LT_KEY = `${key}-lt`
|
||||
const EQ_KEY = key
|
||||
|
||||
return (
|
||||
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||
<NumberDisplay
|
||||
label={label}
|
||||
value={currentValue}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-name="number_filter_content"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={24}
|
||||
className={clx(
|
||||
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout max-h-[var(--radix-popper-available-height)] w-[300px] divide-y overflow-y-auto rounded-lg outline-none"
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
if (
|
||||
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||
"filters_menu_content"
|
||||
) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="p-1">
|
||||
<RadioGroup.Root
|
||||
value={operator}
|
||||
onValueChange={(val) => setOperator(val as Comparison)}
|
||||
className="flex flex-col items-start"
|
||||
orientation="vertical"
|
||||
autoFocus
|
||||
>
|
||||
{operators.map((o) => (
|
||||
<RadioGroup.Item
|
||||
key={o.operator}
|
||||
value={o.operator}
|
||||
className="txt-compact-small hover:bg-ui-bg-base-hover focus-visible:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed transition-fg grid w-full grid-cols-[20px_1fr] gap-2 rounded-[4px] px-2 py-1.5 text-left outline-none"
|
||||
>
|
||||
<div className="size-5">
|
||||
<RadioGroup.Indicator>
|
||||
<EllipseMiniSolid />
|
||||
</RadioGroup.Indicator>
|
||||
</div>
|
||||
<span className="w-full">{o.label}</span>
|
||||
</RadioGroup.Item>
|
||||
))}
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
<div>
|
||||
{operator === "range" ? (
|
||||
<div className="px-1 pb-3 pt-1" key="range">
|
||||
<div className="px-2 py-1.5">
|
||||
<Label size="xsmall" weight="plus" htmlFor={GT_KEY}>
|
||||
{t("filters.compare.greaterThan")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="px-2 py-0.5">
|
||||
<Input
|
||||
name={GT_KEY}
|
||||
size="small"
|
||||
type="number"
|
||||
defaultValue={getValue(currentValue, "gt")}
|
||||
onChange={(e) => debouncedOnChange(e, "gt")}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1.5">
|
||||
<Label size="xsmall" weight="plus" htmlFor={LT_KEY}>
|
||||
{t("filters.compare.lessThan")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="px-2 py-0.5">
|
||||
<Input
|
||||
name={LT_KEY}
|
||||
size="small"
|
||||
type="number"
|
||||
defaultValue={getValue(currentValue, "lt")}
|
||||
onChange={(e) => debouncedOnChange(e, "lt")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-1 pb-3 pt-1" key="exact">
|
||||
<div className="px-2 py-1.5">
|
||||
<Label size="xsmall" weight="plus" htmlFor={EQ_KEY}>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="px-2 py-0.5">
|
||||
<Input
|
||||
name={EQ_KEY}
|
||||
size="small"
|
||||
type="number"
|
||||
defaultValue={getValue(currentValue, "eq")}
|
||||
onChange={(e) => debouncedOnChange(e, "eq")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const NumberDisplay = ({
|
||||
label,
|
||||
value,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string
|
||||
value?: string[]
|
||||
onRemove: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const handleRemove = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(value?.join(",") || "{}")
|
||||
let displayValue = ""
|
||||
|
||||
if (typeof parsed === "object") {
|
||||
const parts = []
|
||||
if (parsed.gt) {
|
||||
parts.push(t("filters.compare.greaterThanLabel", { value: parsed.gt }))
|
||||
}
|
||||
|
||||
if (parsed.lt) {
|
||||
parts.push(
|
||||
t("filters.compare.lessThanLabel", {
|
||||
value: parsed.lt,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
displayValue = parts.join(` ${t("filters.compare.andLabel")} `)
|
||||
}
|
||||
|
||||
if (typeof parsed === "number") {
|
||||
displayValue = parsed.toString()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Trigger
|
||||
asChild
|
||||
className={clx(
|
||||
"bg-ui-bg-field transition-fg shadow-borders-base text-ui-fg-subtle flex cursor-pointer select-none items-center rounded-md",
|
||||
"hover:bg-ui-bg-field-hover",
|
||||
"data-[state=open]:bg-ui-bg-field-hover"
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={clx("flex items-center justify-center px-2 py-1", {
|
||||
"border-r": !!value,
|
||||
})}
|
||||
>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
{!!value && (
|
||||
<div className="border-r p-1 px-2">
|
||||
<Text
|
||||
size="small"
|
||||
weight="plus"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted"
|
||||
>
|
||||
{t("general.is")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{value && (
|
||||
<div className="flex items-center">
|
||||
<div className="border-r p-1 px-2">
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{displayValue}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{value && (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className={clx(
|
||||
"text-ui-fg-muted transition-fg flex items-center justify-center p-1",
|
||||
"hover:bg-ui-bg-subtle-hover",
|
||||
"active:bg-ui-bg-subtle-pressed active:text-ui-fg-base"
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
const parseValue = (value: string[] | null | undefined) => {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const val = value.join(",")
|
||||
if (!val) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return JSON.parse(val)
|
||||
}
|
||||
|
||||
const getValue = (
|
||||
value: string[] | null | undefined,
|
||||
key: Operator
|
||||
): number | undefined => {
|
||||
const parsed = parseValue(value)
|
||||
|
||||
if (typeof parsed === "object") {
|
||||
return parsed[key]
|
||||
}
|
||||
if (typeof parsed === "number" && key === "eq") {
|
||||
return parsed
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const getOperator = (value?: string[] | null): Comparison | undefined => {
|
||||
const parsed = parseValue(value)
|
||||
|
||||
return typeof parsed === "object" ? "range" : "exact"
|
||||
}
|
||||
+2
-1
@@ -61,6 +61,7 @@ export const SelectFilter = ({
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearch("")
|
||||
|
||||
if (searchRef) {
|
||||
searchRef.focus()
|
||||
}
|
||||
@@ -112,7 +113,7 @@ export const SelectFilter = ({
|
||||
ref={setSearchRef}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
className="txt-compact-small placeholder:text-ui-fg-muted outline-none"
|
||||
className="txt-compact-small placeholder:text-ui-fg-muted bg-transparent outline-none"
|
||||
placeholder="Search"
|
||||
/>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import { Input, Label, Text, clx } from "@medusajs/ui"
|
||||
import * as Popover from "@radix-ui/react-popover"
|
||||
import { debounce } from "lodash"
|
||||
import { ChangeEvent, useCallback, useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSelectedParams } from "../hooks"
|
||||
import { useDataTableFilterContext } from "./context"
|
||||
import { IFilter } from "./types"
|
||||
|
||||
type StringFilterProps = IFilter
|
||||
|
||||
export const StringFilter = ({
|
||||
filter,
|
||||
prefix,
|
||||
openOnMount,
|
||||
}: StringFilterProps) => {
|
||||
const [open, setOpen] = useState(openOnMount)
|
||||
|
||||
const { key, label } = filter
|
||||
|
||||
const { removeFilter } = useDataTableFilterContext()
|
||||
const selectedParams = useSelectedParams({ param: key, prefix })
|
||||
|
||||
const query = selectedParams.get()
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const debouncedOnChange = useCallback(
|
||||
debounce((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
|
||||
if (!value) {
|
||||
selectedParams.delete()
|
||||
} else {
|
||||
selectedParams.add(value)
|
||||
}
|
||||
}, 500),
|
||||
[selectedParams]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedOnChange.cancel()
|
||||
}
|
||||
}, [debouncedOnChange])
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open)
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (!open && !query.length) {
|
||||
timeoutId = setTimeout(() => {
|
||||
removeFilter(key)
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemove = () => {
|
||||
selectedParams.delete()
|
||||
removeFilter(key)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||
<StringDisplay label={label} value={query?.[0]} onRemove={handleRemove} />
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
hideWhenDetached
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
className={clx(
|
||||
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout z-[1] h-full max-h-[200px] w-[300px] overflow-hidden rounded-lg outline-none"
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
if (e.target instanceof HTMLElement) {
|
||||
if (
|
||||
e.target.attributes.getNamedItem("data-name")?.value ===
|
||||
"filters_menu_content"
|
||||
) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="px-1 pb-3 pt-1">
|
||||
<div className="px-2 py-1.5">
|
||||
<Label size="xsmall" weight="plus" htmlFor={key}>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="px-2 py-0.5">
|
||||
<Input
|
||||
name={key}
|
||||
size="small"
|
||||
defaultValue={query?.[0] || undefined}
|
||||
onChange={debouncedOnChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const StringDisplay = ({
|
||||
label,
|
||||
value,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string
|
||||
value?: string
|
||||
onRemove: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
<div
|
||||
className={clx(
|
||||
"bg-ui-bg-field transition-fg shadow-borders-base text-ui-fg-subtle flex cursor-pointer select-none items-center overflow-hidden rounded-md",
|
||||
"hover:bg-ui-bg-field-hover",
|
||||
"data-[state=open]:bg-ui-bg-field-hover"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
"flex items-center justify-center whitespace-nowrap px-2 py-1",
|
||||
{
|
||||
"border-r": !!value,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Text size="small" weight="plus" leading="compact">
|
||||
{label}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex w-full items-center overflow-hidden">
|
||||
{!!value && (
|
||||
<div className="border-r p-1 px-2">
|
||||
<Text
|
||||
size="small"
|
||||
weight="plus"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted"
|
||||
>
|
||||
{t("general.is")}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{!!value && (
|
||||
<div className="flex-1 overflow-hidden border-r p-1 px-2">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
weight="plus"
|
||||
className="truncate text-nowrap"
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!value && (
|
||||
<div>
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className={clx(
|
||||
"text-ui-fg-muted transition-fg flex items-center justify-center p-1",
|
||||
"hover:bg-ui-bg-subtle-hover",
|
||||
"active:bg-ui-bg-subtle-pressed active:text-ui-fg-base"
|
||||
)}
|
||||
>
|
||||
<XMarkMini />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
)
|
||||
}
|
||||
+2
-3
@@ -188,7 +188,6 @@ export const DataTableRoot = <TData,>({
|
||||
data-selected={row.getIsSelected()}
|
||||
className={clx(
|
||||
"transition-fg group/row [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||
"[&:has(td_a:focus-visible)_td]:bg-ui-bg-base-pressed",
|
||||
{
|
||||
"cursor-pointer": !!to,
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
@@ -215,8 +214,8 @@ export const DataTableRoot = <TData,>({
|
||||
return (
|
||||
<Table.Cell
|
||||
key={cell.id}
|
||||
className={clx("has-[a]:cursor-pointer", {
|
||||
"bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-[:has(td_a:focus)]/row:bg-ui-bg-base-pressed group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
className={clx({
|
||||
"bg-ui-bg-base group-data-[selected=true]/row:bg-ui-bg-highlight group-data-[selected=true]/row:group-hover/row:bg-ui-bg-highlight-hover group-hover/row:bg-ui-bg-base-hover transition-fg sticky left-0 after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
isStickyCell,
|
||||
"left-[68px]":
|
||||
isStickyCell && hasSelect && !isSelectCell,
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import format from "date-fns/format"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
type DateCellProps = {
|
||||
date: Date
|
||||
date: Date | string
|
||||
}
|
||||
|
||||
export const DateCell = ({ date }: DateCellProps) => {
|
||||
|
||||
@@ -223,6 +223,38 @@ const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/inventory",
|
||||
handle: {
|
||||
crumb: () => "Inventory",
|
||||
},
|
||||
lazy: () => import("../../routes/inventory/inventory-list"),
|
||||
},
|
||||
{
|
||||
path: "/reservations",
|
||||
handle: {
|
||||
crumb: () => "Reservations",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () =>
|
||||
import("../../routes/reservations/reservation-list"),
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () =>
|
||||
import("../../routes/reservations/reservation-detail"),
|
||||
// children: [
|
||||
// {
|
||||
// path: "edit",
|
||||
// lazy: () =>
|
||||
// import("../../routes/reservations/reservation-edit"),
|
||||
// },
|
||||
// ],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/customers",
|
||||
handle: {
|
||||
@@ -335,13 +367,6 @@ const router = createBrowserRouter([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/inventory",
|
||||
handle: {
|
||||
crumb: () => "Inventory",
|
||||
},
|
||||
lazy: () => import("../../routes/inventory/list"),
|
||||
},
|
||||
{
|
||||
path: "/discounts",
|
||||
handle: {
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./inventory-list-table"
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { InventoryItemDTO } from "@medusajs/types"
|
||||
import { usePrompt } from "@medusajs/ui"
|
||||
import { useAdminDeleteInventoryItem } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
|
||||
export const InventoryActions = ({ item }: { item: InventoryItemDTO }) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const { mutateAsync } = useAdminDeleteInventoryItem(item.id)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("inventory.deleteWarning"),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync()
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <PencilSquare />,
|
||||
label: t("actions.edit"),
|
||||
to: `${item.id}/edit`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <Trash />,
|
||||
label: t("actions.delete"),
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { Container, Heading } from "@medusajs/ui"
|
||||
import { useAdminInventoryItems } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useInventoryTableColumns } from "./use-inventory-table-columns"
|
||||
import { useInventoryTableFilters } from "./use-inventory-table-filters"
|
||||
import { useInventoryTableQuery } from "./use-inventory-table-query"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export const InventoryListTable = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { searchParams, raw } = useInventoryTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
const { inventory_items, count, isLoading, isError, error } =
|
||||
useAdminInventoryItems(
|
||||
{
|
||||
...searchParams,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const filters = useInventoryTableFilters()
|
||||
const columns = useInventoryTableColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: inventory_items || [],
|
||||
columns,
|
||||
count,
|
||||
enablePagination: true,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{t("inventory.domain")}</Heading>
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
count={count}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
search
|
||||
filters={filters}
|
||||
queryObject={raw}
|
||||
orderBy={["title", "sku", "stocked_quantity", "reserved_quantity"]}
|
||||
navigateTo={(row) => `${row.id}`}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { ProductVariant } from "@medusajs/medusa"
|
||||
import { InventoryItemDTO } from "@medusajs/types"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
|
||||
import { InventoryActions } from "./inventory-actions"
|
||||
|
||||
/**
|
||||
* Adds missing properties to the InventoryItemDTO type.
|
||||
*/
|
||||
interface ExtendedInventoryItem extends InventoryItemDTO {
|
||||
variants?: ProductVariant[] | null
|
||||
stocked_quantity?: number
|
||||
reserved_quantity?: number
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<ExtendedInventoryItem>()
|
||||
|
||||
export const useInventoryTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("title", {
|
||||
header: t("fields.title"),
|
||||
cell: ({ getValue }) => {
|
||||
const title = getValue()
|
||||
|
||||
if (!title) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("sku", {
|
||||
header: t("fields.sku"),
|
||||
cell: ({ getValue }) => {
|
||||
const sku = getValue()
|
||||
|
||||
if (!sku) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span className="truncate">{sku}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("variants", {
|
||||
header: t("fields.variant"),
|
||||
cell: ({ getValue }) => {
|
||||
const variants = getValue()
|
||||
|
||||
if (!variants || variants.length === 0) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
/**
|
||||
* There is always only one variant despite it being an array,
|
||||
* so we can safely access the first element.
|
||||
*/
|
||||
const variant = variants[0]
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span className="truncate">{variant.title}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("reserved_quantity", {
|
||||
header: t("inventory.reserved"),
|
||||
cell: ({ getValue }) => {
|
||||
const quantity = getValue()
|
||||
|
||||
if (!quantity) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span className="truncate">{quantity}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("stocked_quantity", {
|
||||
header: t("fields.inStock"),
|
||||
cell: ({ getValue }) => {
|
||||
const quantity = getValue()
|
||||
|
||||
if (!quantity) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span className="truncate">{quantity}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => <InventoryActions item={row.original} />,
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { useAdminStockLocations } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Filter } from "../../../../../components/table/data-table"
|
||||
|
||||
export const useInventoryTableFilters = () => {
|
||||
const { t } = useTranslation()
|
||||
const { stock_locations } = useAdminStockLocations({
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const filters: Filter[] = []
|
||||
|
||||
if (stock_locations) {
|
||||
const stockLocationFilter: Filter = {
|
||||
type: "select",
|
||||
options: stock_locations.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.id,
|
||||
})),
|
||||
key: "location_id",
|
||||
searchable: true,
|
||||
label: t("fields.location"),
|
||||
}
|
||||
|
||||
filters.push(stockLocationFilter)
|
||||
}
|
||||
|
||||
filters.push({
|
||||
type: "string",
|
||||
key: "material",
|
||||
label: t("fields.material"),
|
||||
})
|
||||
|
||||
filters.push({
|
||||
type: "string",
|
||||
key: "sku",
|
||||
label: t("fields.sku"),
|
||||
})
|
||||
|
||||
filters.push({
|
||||
type: "string",
|
||||
key: "mid_code",
|
||||
label: t("fields.midCode"),
|
||||
})
|
||||
|
||||
filters.push({
|
||||
type: "number",
|
||||
key: "height",
|
||||
label: t("fields.height"),
|
||||
})
|
||||
|
||||
filters.push({
|
||||
type: "number",
|
||||
key: "width",
|
||||
label: t("fields.width"),
|
||||
})
|
||||
|
||||
filters.push({
|
||||
type: "number",
|
||||
key: "length",
|
||||
label: t("fields.length"),
|
||||
})
|
||||
|
||||
filters.push({
|
||||
type: "number",
|
||||
key: "weight",
|
||||
label: t("fields.weight"),
|
||||
})
|
||||
|
||||
filters.push({
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: t("fields.true"), value: "true" },
|
||||
{ label: t("fields.false"), value: "false" },
|
||||
],
|
||||
key: "requires_shipping",
|
||||
multiple: false,
|
||||
label: t("fields.requiresShipping"),
|
||||
})
|
||||
|
||||
return filters
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { AdminGetInventoryItemsParams } from "@medusajs/medusa"
|
||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||
|
||||
export const useInventoryTableQuery = ({
|
||||
pageSize = 20,
|
||||
prefix,
|
||||
}: {
|
||||
pageSize?: number
|
||||
prefix?: string
|
||||
}) => {
|
||||
const raw = useQueryParams(
|
||||
[
|
||||
"location_id",
|
||||
"q",
|
||||
"order",
|
||||
"requires_shipping",
|
||||
"offset",
|
||||
"sku",
|
||||
"material",
|
||||
"mid_code",
|
||||
"order",
|
||||
"weight",
|
||||
"width",
|
||||
"length",
|
||||
"height",
|
||||
],
|
||||
prefix
|
||||
)
|
||||
|
||||
const {
|
||||
offset,
|
||||
weight,
|
||||
width,
|
||||
length,
|
||||
height,
|
||||
requires_shipping,
|
||||
...params
|
||||
} = raw
|
||||
|
||||
const searchParams: AdminGetInventoryItemsParams = {
|
||||
limit: pageSize,
|
||||
offset: offset ? parseInt(offset) : undefined,
|
||||
weight: weight ? JSON.parse(weight) : undefined,
|
||||
width: width ? JSON.parse(width) : undefined,
|
||||
length: length ? JSON.parse(length) : undefined,
|
||||
height: height ? JSON.parse(height) : undefined,
|
||||
requires_shipping: requires_shipping
|
||||
? JSON.parse(requires_shipping)
|
||||
: undefined,
|
||||
...params,
|
||||
}
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { InventoryList as Component } from "./inventory-list"
|
||||
@@ -0,0 +1,9 @@
|
||||
import { InventoryListTable } from "./components/inventory-list-table"
|
||||
|
||||
export const InventoryList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<InventoryListTable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { InventoryList as Component } from "./list";
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Container, Heading } from "@medusajs/ui";
|
||||
|
||||
export const InventoryList = () => {
|
||||
return (
|
||||
<Container>
|
||||
<Heading>Inventory</Heading>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./reservation-general-section"
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { ExtendedReservationItem } from "@medusajs/medusa"
|
||||
import { Container } from "@medusajs/ui"
|
||||
import { useAdminInventoryItem } from "medusa-react"
|
||||
|
||||
type ReservationGeneralSectionProps = {
|
||||
reservation: ExtendedReservationItem
|
||||
}
|
||||
|
||||
// TODO: Its not possible to get the data necessary for this component with the current API
|
||||
|
||||
export const ReservationGeneralSection = ({
|
||||
reservation,
|
||||
}: ReservationGeneralSectionProps) => {
|
||||
const { inventory_item, isLoading, isError, error } = useAdminInventoryItem(
|
||||
reservation.inventory_item_id
|
||||
)
|
||||
|
||||
if (isLoading || !inventory_item) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return <Container className="divide-y p-0"></Container>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ReservationDetail as Component } from "./reservation-edit"
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { useAdminReservation } from "medusa-react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { ReservationGeneralSection } from "./components/reservation-general-section"
|
||||
|
||||
export const ReservationDetail = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { reservation, isLoading, isError, error } = useAdminReservation(id!)
|
||||
|
||||
if (isLoading || !reservation) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<ReservationGeneralSection reservation={reservation} />
|
||||
<JsonViewSection data={reservation} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./reservation-list-table"
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { ExtendedReservationItem } from "@medusajs/medusa"
|
||||
import { usePrompt } from "@medusajs/ui"
|
||||
import { useAdminDeleteReservation } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
|
||||
export const ReservationActions = ({
|
||||
reservation,
|
||||
}: {
|
||||
reservation: ExtendedReservationItem
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
const { mutateAsync } = useAdminDeleteReservation(reservation.id)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("reservations.deleteWarning"),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync()
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
to: `${reservation.id}/edit`,
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.delete"),
|
||||
onClick: handleDelete,
|
||||
icon: <Trash />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { useAdminReservations } from "medusa-react"
|
||||
|
||||
import { Button, Container, Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { reservationListExpand } from "../../constants"
|
||||
import { useReservationTableColumns } from "./use-reservation-table-columns"
|
||||
import { useReservationTableFilters } from "./use-reservation-table-filters"
|
||||
import { useReservationTableQuery } from "./use-reservation-table-query"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export const ReservationListTable = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { searchParams, raw } = useReservationTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
const { reservations, count, isLoading, isError, error } =
|
||||
useAdminReservations(
|
||||
{
|
||||
expand: reservationListExpand,
|
||||
...searchParams,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const filters = useReservationTableFilters()
|
||||
const columns = useReservationTableColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: reservations || [],
|
||||
columns,
|
||||
count,
|
||||
enablePagination: true,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{t("reservations.domain")}</Heading>
|
||||
<Button variant="secondary" size="small" asChild>
|
||||
<Link to="create">{t("actions.create")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
count={count}
|
||||
isLoading={isLoading}
|
||||
filters={filters}
|
||||
pagination
|
||||
navigateTo={(row) => `${row.id}`}
|
||||
orderBy={["created_at", "updated_at"]}
|
||||
queryObject={raw}
|
||||
search={false}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { ExtendedReservationItem } from "@medusajs/medusa"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { InlineLink } from "../../../../../components/common/inline-link"
|
||||
import { DateCell } from "../../../../../components/table/table-cells/common/date-cell"
|
||||
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
|
||||
import { ReservationActions } from "./reservation-actions"
|
||||
|
||||
const columnHelper = createColumnHelper<ExtendedReservationItem>()
|
||||
|
||||
export const useReservationTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("inventory_item", {
|
||||
header: t("fields.sku"),
|
||||
cell: ({ getValue }) => {
|
||||
const inventoryItem = getValue()
|
||||
|
||||
if (!inventoryItem || !inventoryItem.sku) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span className="truncate">{inventoryItem.sku}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("line_item", {
|
||||
header: t("fields.order"),
|
||||
cell: ({ getValue }) => {
|
||||
const inventoryItem = getValue()
|
||||
|
||||
if (!inventoryItem || !inventoryItem.order?.display_id) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<InlineLink to={`/orders/${inventoryItem.order.id}`}>
|
||||
<span className="truncate">
|
||||
#{inventoryItem.order.display_id}
|
||||
</span>
|
||||
</InlineLink>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("description", {
|
||||
header: t("fields.description"),
|
||||
cell: ({ getValue }) => {
|
||||
const description = getValue()
|
||||
|
||||
if (!description) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span className="truncate">{description}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("created_at", {
|
||||
header: t("fields.created"),
|
||||
cell: ({ getValue }) => {
|
||||
const created = getValue()
|
||||
|
||||
return <DateCell date={created} />
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("quantity", {
|
||||
header: () => (
|
||||
<div className="flex size-full items-center justify-end overflow-hidden text-right">
|
||||
<span className="truncate">{t("fields.quantity")}</span>
|
||||
</div>
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
const quantity = getValue()
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center justify-end overflow-hidden text-right">
|
||||
<span className="truncate">{quantity}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const reservation = row.original
|
||||
|
||||
return <ReservationActions reservation={reservation} />
|
||||
},
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { useAdminStockLocations } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Filter } from "../../../../../components/table/data-table"
|
||||
|
||||
export const useReservationTableFilters = () => {
|
||||
const { t } = useTranslation()
|
||||
const { stock_locations } = useAdminStockLocations({
|
||||
limit: 1000,
|
||||
})
|
||||
|
||||
const filters: Filter[] = []
|
||||
|
||||
if (stock_locations) {
|
||||
const stockLocationFilter: Filter = {
|
||||
type: "select",
|
||||
options: stock_locations.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.id,
|
||||
})),
|
||||
key: "location_id",
|
||||
searchable: true,
|
||||
label: t("fields.location"),
|
||||
}
|
||||
|
||||
filters.push(stockLocationFilter)
|
||||
}
|
||||
|
||||
filters.push({
|
||||
type: "date",
|
||||
key: "created_at",
|
||||
label: t("fields.createdAt"),
|
||||
})
|
||||
|
||||
return filters
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { AdminGetReservationsParams } from "@medusajs/medusa"
|
||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||
|
||||
export const useReservationTableQuery = ({
|
||||
pageSize = 20,
|
||||
prefix,
|
||||
}: {
|
||||
pageSize?: number
|
||||
prefix?: string
|
||||
}) => {
|
||||
const raw = useQueryParams(
|
||||
["location_id", "offset", "created_at", "quantity", "updated_at", "order"],
|
||||
prefix
|
||||
)
|
||||
|
||||
const { location_id, created_at, updated_at, quantity, offset, ...rest } = raw
|
||||
|
||||
const searchParams: AdminGetReservationsParams = {
|
||||
limit: pageSize,
|
||||
offset: offset ? parseInt(offset) : undefined,
|
||||
location_id: location_id,
|
||||
created_at: created_at ? JSON.parse(created_at) : undefined,
|
||||
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
|
||||
quantity: quantity ? JSON.parse(quantity) : undefined,
|
||||
...rest,
|
||||
}
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const reservationListExpand = "line_item,inventory_item"
|
||||
@@ -0,0 +1 @@
|
||||
export { ReservationList as Component } from "./reservation-list"
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { ReservationListTable } from "./components/reservation-list-table"
|
||||
|
||||
export const ReservationList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<ReservationListTable />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user