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:
Kasper Fabricius Kristensen
2024-03-11 08:40:25 +00:00
committed by GitHub
parent d38b5eb790
commit fb25471e92
39 changed files with 1576 additions and 110 deletions
@@ -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",
},
],
},
@@ -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"
@@ -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
@@ -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"
}
@@ -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">
@@ -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>
)
}
@@ -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,
@@ -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: {
@@ -0,0 +1 @@
export * from "./inventory-list-table"
@@ -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,
},
],
},
]}
/>
)
}
@@ -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>
)
}
@@ -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]
)
}
@@ -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
}
@@ -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>
);
};
@@ -0,0 +1 @@
export * from "./reservation-general-section"
@@ -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"
@@ -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>
)
}
@@ -0,0 +1 @@
export * from "./reservation-list-table"
@@ -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 />,
},
],
},
]}
/>
)
}
@@ -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>
)
}
@@ -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]
)
}
@@ -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
}
@@ -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"
@@ -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>
)
}