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,6 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/client-types": patch
|
||||||
|
"@medusajs/medusa": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
fix(medusa): Adds updated_at query param to list-reservations. Fixes OAS for list-inventory-items.
|
||||||
@@ -57,7 +57,28 @@
|
|||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"clearAll": "Clear all"
|
"clearAll": "Clear all",
|
||||||
|
"apply": "Apply"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"date": {
|
||||||
|
"today": "Today",
|
||||||
|
"lastSevenDays": "Last 7 days",
|
||||||
|
"lastThirtyDays": "Last 30 days",
|
||||||
|
"lastNinetyDays": "Last 90 days",
|
||||||
|
"lastTwelveMonths": "Last 12 months",
|
||||||
|
"custom": "Custom"
|
||||||
|
},
|
||||||
|
"compare": {
|
||||||
|
"lessThan": "Less than",
|
||||||
|
"greaterThan": "Greater than",
|
||||||
|
"exact": "Exact",
|
||||||
|
"range": "Range",
|
||||||
|
"lessThanLabel": "less than {{value}}",
|
||||||
|
"greaterThanLabel": "greater than {{value}}",
|
||||||
|
"andLabel": "and"
|
||||||
|
},
|
||||||
|
"addFilter": "Add filter"
|
||||||
},
|
},
|
||||||
"errorBoundary": {
|
"errorBoundary": {
|
||||||
"badRequestTitle": "Bad request",
|
"badRequestTitle": "Bad request",
|
||||||
@@ -125,7 +146,9 @@
|
|||||||
"domain": "Categories"
|
"domain": "Categories"
|
||||||
},
|
},
|
||||||
"inventory": {
|
"inventory": {
|
||||||
"domain": "Inventory"
|
"domain": "Inventory",
|
||||||
|
"reserved": "Reserved",
|
||||||
|
"deleteWarning": "You are about to delete an inventory item. This action cannot be undone."
|
||||||
},
|
},
|
||||||
"giftCards": {
|
"giftCards": {
|
||||||
"domain": "Gift Cards",
|
"domain": "Gift Cards",
|
||||||
@@ -451,6 +474,10 @@
|
|||||||
"removeSalesChannelsWarning_one": "You are about to remove {{count}} sales channel from the location.",
|
"removeSalesChannelsWarning_one": "You are about to remove {{count}} sales channel from the location.",
|
||||||
"removeSalesChannelsWarning_other": "You are about to remove {{count}} sales channels from the location."
|
"removeSalesChannelsWarning_other": "You are about to remove {{count}} sales channels from the location."
|
||||||
},
|
},
|
||||||
|
"reservations": {
|
||||||
|
"domain": "Reservations",
|
||||||
|
"deleteWarning": "You are about to delete a reservation. This action cannot be undone."
|
||||||
|
},
|
||||||
"salesChannels": {
|
"salesChannels": {
|
||||||
"domain": "Sales Channels",
|
"domain": "Sales Channels",
|
||||||
"createSalesChannel": "Create Sales Channel",
|
"createSalesChannel": "Create Sales Channel",
|
||||||
@@ -660,12 +687,17 @@
|
|||||||
"thumbnail": "Thumbnail",
|
"thumbnail": "Thumbnail",
|
||||||
"sku": "SKU",
|
"sku": "SKU",
|
||||||
"managedInventory": "Managed inventory",
|
"managedInventory": "Managed inventory",
|
||||||
|
"inStock": "In stock",
|
||||||
|
"location": "Location",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"variant": "Variant",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"minSubtotal": "Min. Subtotal",
|
"minSubtotal": "Min. Subtotal",
|
||||||
"maxSubtotal": "Max. Subtotal",
|
"maxSubtotal": "Max. Subtotal",
|
||||||
"shippingProfile": "Shipping Profile",
|
"shippingProfile": "Shipping Profile",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"rate": "Rate"
|
"rate": "Rate",
|
||||||
|
"requiresShipping": "Requires shipping"
|
||||||
},
|
},
|
||||||
"dateTime": {
|
"dateTime": {
|
||||||
"years_one": "Year",
|
"years_one": "Year",
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
|
Buildings,
|
||||||
ChevronDownMini,
|
ChevronDownMini,
|
||||||
CurrencyDollar,
|
CurrencyDollar,
|
||||||
MinusMini,
|
MinusMini,
|
||||||
@@ -61,14 +62,14 @@ const Header = () => {
|
|||||||
{fallback ? (
|
{fallback ? (
|
||||||
<Avatar variant="squared" fallback={fallback} />
|
<Avatar variant="squared" fallback={fallback} />
|
||||||
) : (
|
) : (
|
||||||
<Skeleton className="w-8 h-8 rounded-md" />
|
<Skeleton className="h-8 w-8 rounded-md" />
|
||||||
)}
|
)}
|
||||||
{name ? (
|
{name ? (
|
||||||
<Text size="small" weight="plus" leading="compact">
|
<Text size="small" weight="plus" leading="compact">
|
||||||
{store.name}
|
{store.name}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton className="w-[120px] h-[9px]" />
|
<Skeleton className="h-[9px] w-[120px]" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,9 +109,16 @@ const useCoreRoutes = (): Omit<NavItemProps, "pathname">[] => {
|
|||||||
label: t("giftCards.domain"),
|
label: t("giftCards.domain"),
|
||||||
to: "/gift-cards",
|
to: "/gift-cards",
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Buildings />,
|
||||||
|
label: t("inventory.domain"),
|
||||||
|
to: "/inventory",
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: t("inventory.domain"),
|
label: t("reservations.domain"),
|
||||||
to: "/inventory",
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
import { DataTableFilterContext, useDataTableFilterContext } from "./context"
|
import { DataTableFilterContext, useDataTableFilterContext } from "./context"
|
||||||
import { DateFilter } from "./date-filter"
|
import { DateFilter } from "./date-filter"
|
||||||
|
import { NumberFilter } from "./number-filter"
|
||||||
import { SelectFilter } from "./select-filter"
|
import { SelectFilter } from "./select-filter"
|
||||||
|
import { StringFilter } from "./string-filter"
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
label: string
|
label: string
|
||||||
@@ -26,6 +29,14 @@ export type Filter = {
|
|||||||
type: "date"
|
type: "date"
|
||||||
options?: never
|
options?: never
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "string"
|
||||||
|
options?: never
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "number"
|
||||||
|
options?: never
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type DataTableFilterProps = {
|
type DataTableFilterProps = {
|
||||||
@@ -34,6 +45,7 @@ type DataTableFilterProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -60,7 +72,6 @@ export const DataTableFilter = ({ filters, prefix }: DataTableFilterProps) => {
|
|||||||
const key = prefix ? `${prefix}_${filter.key}` : filter.key
|
const key = prefix ? `${prefix}_${filter.key}` : filter.key
|
||||||
const value = params.get(key)
|
const value = params.get(key)
|
||||||
if (value && !activeFilters.find((af) => af.key === filter.key)) {
|
if (value && !activeFilters.find((af) => af.key === filter.key)) {
|
||||||
console.log("adding filter", filter.key, "to active filters")
|
|
||||||
if (filter.type === "select") {
|
if (filter.type === "select") {
|
||||||
setActiveFilters((prev) => [
|
setActiveFilters((prev) => [
|
||||||
...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">
|
<div className="max-w-2/3 flex flex-wrap items-center gap-2">
|
||||||
{activeFilters.map((filter) => {
|
{activeFilters.map((filter) => {
|
||||||
if (filter.type === "select") {
|
switch (filter.type) {
|
||||||
return (
|
case "select":
|
||||||
<SelectFilter
|
return (
|
||||||
key={filter.key}
|
<SelectFilter
|
||||||
filter={filter}
|
key={filter.key}
|
||||||
prefix={prefix}
|
filter={filter}
|
||||||
options={filter.options}
|
prefix={prefix}
|
||||||
multiple={filter.multiple}
|
options={filter.options}
|
||||||
searchable={filter.searchable}
|
multiple={filter.multiple}
|
||||||
openOnMount={filter.openOnMount}
|
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 && (
|
{availableFilters.length > 0 && (
|
||||||
<Popover.Root modal open={open} onOpenChange={setOpen}>
|
<Popover.Root modal open={open} onOpenChange={setOpen}>
|
||||||
<Popover.Trigger asChild id="filters_menu_trigger">
|
<Popover.Trigger asChild id="filters_menu_trigger">
|
||||||
<Button size="small" variant="secondary">
|
<Button size="small" variant="secondary">
|
||||||
Add filter
|
{t("filters.addFilter")}
|
||||||
</Button>
|
</Button>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
className={clx(
|
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"
|
data-name="filters_menu_content"
|
||||||
align="start"
|
align="start"
|
||||||
|
|||||||
+68
-35
@@ -3,8 +3,10 @@ import { DatePicker, Text, clx } from "@medusajs/ui"
|
|||||||
import * as Popover from "@radix-ui/react-popover"
|
import * as Popover from "@radix-ui/react-popover"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import isEqual from "lodash/isEqual"
|
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 { useSelectedParams } from "../hooks"
|
||||||
import { useDataTableFilterContext } from "./context"
|
import { useDataTableFilterContext } from "./context"
|
||||||
import { IFilter } from "./types"
|
import { IFilter } from "./types"
|
||||||
@@ -12,9 +14,21 @@ import { IFilter } from "./types"
|
|||||||
type DateFilterProps = IFilter
|
type DateFilterProps = IFilter
|
||||||
|
|
||||||
type DateComparisonOperator = {
|
type DateComparisonOperator = {
|
||||||
|
/**
|
||||||
|
* The filtered date must be greater than or equal to this value.
|
||||||
|
*/
|
||||||
gte?: string
|
gte?: string
|
||||||
|
/**
|
||||||
|
* The filtered date must be less than or equal to this value.
|
||||||
|
*/
|
||||||
lte?: string
|
lte?: string
|
||||||
|
/**
|
||||||
|
* The filtered date must be less than this value.
|
||||||
|
*/
|
||||||
lt?: string
|
lt?: string
|
||||||
|
/**
|
||||||
|
* The filtered date must be greater than this value.
|
||||||
|
*/
|
||||||
gt?: string
|
gt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,10 +39,14 @@ export const DateFilter = ({
|
|||||||
}: DateFilterProps) => {
|
}: DateFilterProps) => {
|
||||||
const [open, setOpen] = useState(openOnMount)
|
const [open, setOpen] = useState(openOnMount)
|
||||||
const [showCustom, setShowCustom] = useState(false)
|
const [showCustom, setShowCustom] = useState(false)
|
||||||
|
|
||||||
const { key, label } = filter
|
const { key, label } = filter
|
||||||
|
|
||||||
const { removeFilter } = useDataTableFilterContext()
|
const { removeFilter } = useDataTableFilterContext()
|
||||||
const selectedParams = useSelectedParams({ param: key, prefix })
|
const selectedParams = useSelectedParams({ param: key, prefix })
|
||||||
|
|
||||||
|
const presets = usePresets()
|
||||||
|
|
||||||
const handleSelectPreset = (value: DateComparisonOperator) => {
|
const handleSelectPreset = (value: DateComparisonOperator) => {
|
||||||
selectedParams.add(JSON.stringify(value))
|
selectedParams.add(JSON.stringify(value))
|
||||||
setShowCustom(false)
|
setShowCustom(false)
|
||||||
@@ -100,7 +118,7 @@ export const DateFilter = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root open={open} onOpenChange={handleOpenChange}>
|
<Popover.Root modal open={open} onOpenChange={handleOpenChange}>
|
||||||
<DateDisplay label={label} value={displayValue} onRemove={handleRemove} />
|
<DateDisplay label={label} value={displayValue} onRemove={handleRemove} />
|
||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
@@ -167,7 +185,7 @@ export const DateFilter = ({
|
|||||||
>
|
>
|
||||||
<EllipseMiniSolid />
|
<EllipseMiniSolid />
|
||||||
</div>
|
</div>
|
||||||
Custom
|
{t("filters.date.custom")}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -275,38 +293,53 @@ const DateDisplay = ({ label, value, onRemove }: DateDisplayProps) => {
|
|||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
const presets: { label: string; value: DateComparisonOperator }[] = [
|
const usePresets = () => {
|
||||||
{
|
const { t } = useTranslation()
|
||||||
label: "Today",
|
|
||||||
value: {
|
return useMemo(
|
||||||
gte: today.toISOString(),
|
() => [
|
||||||
},
|
{
|
||||||
},
|
label: t("filters.date.today"),
|
||||||
{
|
value: {
|
||||||
label: "Last 7 days",
|
gte: today.toISOString(),
|
||||||
value: {
|
},
|
||||||
gte: new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days ago
|
},
|
||||||
},
|
{
|
||||||
},
|
label: t("filters.date.lastSevenDays"),
|
||||||
{
|
value: {
|
||||||
label: "Last 30 days",
|
gte: new Date(
|
||||||
value: {
|
today.getTime() - 7 * 24 * 60 * 60 * 1000
|
||||||
gte: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days ago
|
).toISOString(), // 7 days ago
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Last 90 days",
|
label: t("filters.date.lastThirtyDays"),
|
||||||
value: {
|
value: {
|
||||||
gte: new Date(today.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(), // 90 days ago
|
gte: new Date(
|
||||||
},
|
today.getTime() - 30 * 24 * 60 * 60 * 1000
|
||||||
},
|
).toISOString(), // 30 days ago
|
||||||
{
|
},
|
||||||
label: "Last 12 months",
|
},
|
||||||
value: {
|
{
|
||||||
gte: new Date(today.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(), // 365 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[]) => {
|
const parseDateComparison = (value: string[]) => {
|
||||||
return value?.length
|
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 = () => {
|
const handleClearSearch = () => {
|
||||||
setSearch("")
|
setSearch("")
|
||||||
|
|
||||||
if (searchRef) {
|
if (searchRef) {
|
||||||
searchRef.focus()
|
searchRef.focus()
|
||||||
}
|
}
|
||||||
@@ -112,7 +113,7 @@ export const SelectFilter = ({
|
|||||||
ref={setSearchRef}
|
ref={setSearchRef}
|
||||||
value={search}
|
value={search}
|
||||||
onValueChange={setSearch}
|
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"
|
placeholder="Search"
|
||||||
/>
|
/>
|
||||||
<div className="flex h-5 w-5 items-center justify-center">
|
<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()}
|
data-selected={row.getIsSelected()}
|
||||||
className={clx(
|
className={clx(
|
||||||
"transition-fg group/row [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
"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,
|
"cursor-pointer": !!to,
|
||||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||||
@@ -215,8 +214,8 @@ export const DataTableRoot = <TData,>({
|
|||||||
return (
|
return (
|
||||||
<Table.Cell
|
<Table.Cell
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className={clx("has-[a]:cursor-pointer", {
|
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-[: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-['']":
|
"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,
|
isStickyCell,
|
||||||
"left-[68px]":
|
"left-[68px]":
|
||||||
isStickyCell && hasSelect && !isSelectCell,
|
isStickyCell && hasSelect && !isSelectCell,
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import format from "date-fns/format"
|
|||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
type DateCellProps = {
|
type DateCellProps = {
|
||||||
date: Date
|
date: Date | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DateCell = ({ date }: DateCellProps) => {
|
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",
|
path: "/customers",
|
||||||
handle: {
|
handle: {
|
||||||
@@ -335,13 +367,6 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/inventory",
|
|
||||||
handle: {
|
|
||||||
crumb: () => "Inventory",
|
|
||||||
},
|
|
||||||
lazy: () => import("../../routes/inventory/list"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/discounts",
|
path: "/discounts",
|
||||||
handle: {
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ export interface AdminGetInventoryItemsParams {
|
|||||||
* term to search inventory item's sku, title, and description.
|
* term to search inventory item's sku, title, and description.
|
||||||
*/
|
*/
|
||||||
q?: string
|
q?: string
|
||||||
|
/**
|
||||||
|
* Field to sort-order inventory items by.
|
||||||
|
*/
|
||||||
|
order?: string
|
||||||
/**
|
/**
|
||||||
* Filter by location IDs.
|
* Filter by location IDs.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
import { IsBoolean, IsOptional, IsString } from "class-validator"
|
import { IsBoolean, IsOptional, IsString } from "class-validator"
|
||||||
|
import { Request, Response } from "express"
|
||||||
|
import {
|
||||||
|
ProductVariantInventoryService,
|
||||||
|
ProductVariantService,
|
||||||
|
} from "../../../../services"
|
||||||
import {
|
import {
|
||||||
NumericalComparisonOperator,
|
NumericalComparisonOperator,
|
||||||
StringComparisonOperator,
|
StringComparisonOperator,
|
||||||
extendedFindParamsMixin,
|
extendedFindParamsMixin,
|
||||||
} from "../../../../types/common"
|
} from "../../../../types/common"
|
||||||
import {
|
import { joinLevels } from "./utils/join-levels"
|
||||||
ProductVariantInventoryService,
|
import { joinVariants } from "./utils/join-variants"
|
||||||
ProductVariantService,
|
|
||||||
} from "../../../../services"
|
|
||||||
import { Request, Response } from "express"
|
|
||||||
import { getLevelsByInventoryItemId, joinLevels } from "./utils/join-levels"
|
|
||||||
import {
|
|
||||||
getVariantsByInventoryItemId,
|
|
||||||
joinVariants,
|
|
||||||
} from "./utils/join-variants"
|
|
||||||
|
|
||||||
import { IInventoryService } from "@medusajs/types"
|
import { IInventoryService } from "@medusajs/types"
|
||||||
import { IsType } from "../../../../utils/validators/is-type"
|
|
||||||
import { Transform } from "class-transformer"
|
import { Transform } from "class-transformer"
|
||||||
|
import { IsType } from "../../../../utils/validators/is-type"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @oas [get] /admin/inventory-items
|
* @oas [get] /admin/inventory-items
|
||||||
@@ -31,6 +28,7 @@ import { Transform } from "class-transformer"
|
|||||||
* - (query) expand {string} Comma-separated relations that should be expanded in each returned inventory item.
|
* - (query) expand {string} Comma-separated relations that should be expanded in each returned inventory item.
|
||||||
* - (query) fields {string} Comma-separated fields that should be included in the returned inventory item.
|
* - (query) fields {string} Comma-separated fields that should be included in the returned inventory item.
|
||||||
* - (query) q {string} term to search inventory item's sku, title, and description.
|
* - (query) q {string} term to search inventory item's sku, title, and description.
|
||||||
|
* - (query) order {string} Field to sort-order inventory items by.
|
||||||
* - in: query
|
* - in: query
|
||||||
* name: location_id
|
* name: location_id
|
||||||
* style: form
|
* style: form
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import {
|
|
||||||
DateComparisonOperator,
|
|
||||||
extendedFindParamsMixin,
|
|
||||||
NumericalComparisonOperator,
|
|
||||||
StringComparisonOperator,
|
|
||||||
} from "../../../../types/common"
|
|
||||||
import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator"
|
import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator"
|
||||||
import { Request, Response } from "express"
|
import { Request, Response } from "express"
|
||||||
|
import {
|
||||||
|
DateComparisonOperator,
|
||||||
|
NumericalComparisonOperator,
|
||||||
|
StringComparisonOperator,
|
||||||
|
extendedFindParamsMixin,
|
||||||
|
} from "../../../../types/common"
|
||||||
|
|
||||||
import { EntityManager } from "typeorm"
|
|
||||||
import { IInventoryService } from "@medusajs/types"
|
import { IInventoryService } from "@medusajs/types"
|
||||||
import { IsType } from "../../../../utils/validators/is-type"
|
import { promiseAll } from "@medusajs/utils"
|
||||||
import { LineItemService } from "../../../../services"
|
|
||||||
import { Type } from "class-transformer"
|
import { Type } from "class-transformer"
|
||||||
|
import { EntityManager } from "typeorm"
|
||||||
|
import { LineItemService } from "../../../../services"
|
||||||
|
import { IsType } from "../../../../utils/validators/is-type"
|
||||||
import { joinInventoryItems } from "./utils/join-inventory-items"
|
import { joinInventoryItems } from "./utils/join-inventory-items"
|
||||||
import { joinLineItems } from "./utils/join-line-items"
|
import { joinLineItems } from "./utils/join-line-items"
|
||||||
import { promiseAll } from "@medusajs/utils"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @oas [get] /admin/reservations
|
* @oas [get] /admin/reservations
|
||||||
@@ -288,7 +288,15 @@ export class AdminGetReservationsParams extends extendedFindParamsMixin({
|
|||||||
created_at?: DateComparisonOperator
|
created_at?: DateComparisonOperator
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* String filters tp apply on the reservations' `description` field.
|
* Date filters to apply on the reservations' `updated_at` field.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => DateComparisonOperator)
|
||||||
|
updated_at?: DateComparisonOperator
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String filters to apply on the reservations' `description` field.
|
||||||
*/
|
*/
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsType([StringComparisonOperator, String])
|
@IsType([StringComparisonOperator, String])
|
||||||
|
|||||||
Reference in New Issue
Block a user