feat(dashboard): Initial pricing domain (#6996)
**What** - Sets up the initial work for Pricing domain - Fixes Store domain **Todo in follow up PR** - Translations - Add status when creating the Price List and allow updating it - Improve DataGrid component - Add missing functionality once backend support is added (customer_groups, region prices and update prices) CLOSES CORE-1931
This commit is contained in:
@@ -38,12 +38,12 @@
|
||||
"qs": "^6.12.0",
|
||||
"react": "18.2.0",
|
||||
"react-country-flag": "^3.1.0",
|
||||
"react-currency-input-field": "^3.6.11",
|
||||
"react-dom": "18.2.0",
|
||||
"react-focus-lock": "^2.11.1",
|
||||
"react-hook-form": "7.49.1",
|
||||
"react-i18next": "13.5.0",
|
||||
"react-jwt": "^1.2.0",
|
||||
"react-resizable-panels": "^2.0.9",
|
||||
"react-resizable-panels": "^2.0.16",
|
||||
"react-router-dom": "6.20.1",
|
||||
"zod": "3.22.4"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum GridCellType {
|
||||
VOID = "void",
|
||||
READONLY = "readonly",
|
||||
EDITABLE = "editable",
|
||||
OVERLAY = "overlay",
|
||||
}
|
||||
|
||||
export const NON_INTERACTIVE_CELL_TYPES = [
|
||||
GridCellType.VOID,
|
||||
GridCellType.READONLY,
|
||||
]
|
||||
@@ -1,203 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Product, ProductVariant } from "@medusajs/medusa"
|
||||
import { Button, Container } from "@medusajs/ui"
|
||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
|
||||
import { useAdminProducts } from "medusa-react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { Thumbnail } from "../common/thumbnail"
|
||||
import { DataGrid } from "./data-grid"
|
||||
import { TextField } from "./grid-fields/common/text-field"
|
||||
import { DisplayField } from "./grid-fields/non-interactive/display-field"
|
||||
import { DataGridMeta } from "./types"
|
||||
|
||||
const ProductEditorSchema = zod.object({
|
||||
products: zod.record(
|
||||
zod.object({
|
||||
variants: zod.record(
|
||||
zod.object({
|
||||
title: zod.string(),
|
||||
sku: zod.string(),
|
||||
ean: zod.string().optional(),
|
||||
upc: zod.string().optional(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
type ProductEditorSchemaType = zod.infer<typeof ProductEditorSchema>
|
||||
type VariantObject = ProductEditorSchemaType["products"]["id"]["variants"]
|
||||
|
||||
const getVariantRows = (row: Product | ProductVariant) => {
|
||||
if ("variants" in row) {
|
||||
return row.variants
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo component to test the data grid.
|
||||
*
|
||||
* To be deleted when the feature is implemented.
|
||||
*/
|
||||
export const DataGridDemo = () => {
|
||||
const form = useForm<ProductEditorSchemaType>({
|
||||
resolver: zodResolver(ProductEditorSchema),
|
||||
})
|
||||
|
||||
const { setValue } = form
|
||||
|
||||
const { products, isLoading } = useAdminProducts(
|
||||
{
|
||||
expand: "variants,variants.prices",
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && products) {
|
||||
products.forEach((product) => {
|
||||
setValue(`products.${product.id}.variants`, {
|
||||
...product.variants.reduce((variants, variant) => {
|
||||
variants[variant.id!] = {
|
||||
title: variant.title || "",
|
||||
sku: variant.sku || "",
|
||||
ean: variant.ean || "",
|
||||
upc: variant.upc || "",
|
||||
}
|
||||
return variants
|
||||
}, {} as VariantObject),
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [products, isLoading, setValue])
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const initializing = isLoading || !products
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
console.log("submitting", data)
|
||||
})
|
||||
|
||||
return (
|
||||
<Container className="overflow-hidden p-0">
|
||||
<DataGrid
|
||||
isLoading={initializing}
|
||||
data={products as Product[]}
|
||||
columns={columns}
|
||||
state={form}
|
||||
getSubRows={getVariantRows}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2 border-t p-4">
|
||||
<Button size="small" onClick={handleSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine if a row is a product or a variant.
|
||||
*/
|
||||
const isProduct = (row: Product | ProductVariant): row is Product => {
|
||||
return "variants" in row
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Product | ProductVariant>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const colDefs: ColumnDef<Product | ProductVariant>[] = useMemo(() => {
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: t("fields.title"),
|
||||
header: "Title",
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return (
|
||||
<DisplayField>
|
||||
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||
<Thumbnail src={entity.thumbnail} />
|
||||
<span className="truncate">{entity.title}</span>
|
||||
</div>
|
||||
</DisplayField>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
field={`products.${entity.product_id}.variants.${entity.id}.title`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 350,
|
||||
}),
|
||||
columnHelper.accessor("sku", {
|
||||
header: t("fields.sku"),
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return <DisplayField />
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
field={`products.${entity.product_id}.variants.${entity.id}.sku`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("ean", {
|
||||
header: "EAN",
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return <DisplayField />
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
field={`products.${entity.product_id}.variants.${entity.id}.ean`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("upc", {
|
||||
header: "UPC",
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return <DisplayField />
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
field={`products.${entity.product_id}.variants.${entity.id}.upc`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
]
|
||||
}, [t])
|
||||
|
||||
return colDefs
|
||||
}
|
||||
+35
-15
@@ -20,6 +20,7 @@ import {
|
||||
Command,
|
||||
useCommandHistory,
|
||||
} from "../../../../hooks/use-command-history"
|
||||
import { GridCellType, NON_INTERACTIVE_CELL_TYPES } from "../../constants"
|
||||
|
||||
type FieldCoordinates = {
|
||||
column: number
|
||||
@@ -28,9 +29,9 @@ type FieldCoordinates = {
|
||||
|
||||
export interface DataGridRootProps<
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
> {
|
||||
data: TData[]
|
||||
data?: TData[]
|
||||
columns: ColumnDef<TData>[]
|
||||
state: UseFormReturn<TFieldValues>
|
||||
getSubRows: (row: TData) => TData[] | undefined
|
||||
@@ -40,9 +41,9 @@ const ROW_HEIGHT = 40
|
||||
|
||||
export const DataGridRoot = <
|
||||
TData,
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TFieldValues extends FieldValues = FieldValues
|
||||
>({
|
||||
data,
|
||||
data = [],
|
||||
columns,
|
||||
state,
|
||||
getSubRows,
|
||||
@@ -99,17 +100,35 @@ export const DataGridRoot = <
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlurAnchor = () => {
|
||||
const activeElement = document.activeElement
|
||||
|
||||
if (anchor && activeElement instanceof HTMLElement) {
|
||||
activeElement.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const isNonInteractive = (element: HTMLElement) => {
|
||||
const type = element.getAttribute("data-cell-type")
|
||||
|
||||
if (!type) {
|
||||
return true
|
||||
}
|
||||
|
||||
return NON_INTERACTIVE_CELL_TYPES.includes(type as GridCellType)
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: ReactMouseEvent<HTMLTableCellElement>) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
||||
const target = e.target
|
||||
|
||||
/**
|
||||
* Check if the click was on a presentation element.
|
||||
* If so, we don't want to set the anchor.
|
||||
*/
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.querySelector("[data-role=presentation]")
|
||||
) {
|
||||
if (target instanceof HTMLElement && isNonInteractive(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -121,6 +140,9 @@ export const DataGridRoot = <
|
||||
if (e.detail === 2 || isAnchor) {
|
||||
handleFocusInner(e.currentTarget)
|
||||
return
|
||||
} else {
|
||||
// reset focus so the previous cell doesn't keep the focus
|
||||
handleBlurAnchor()
|
||||
}
|
||||
|
||||
const coordinates: FieldCoordinates = {
|
||||
@@ -157,10 +179,7 @@ export const DataGridRoot = <
|
||||
* Check if the click was on a presentation element.
|
||||
* If so, we don't want to add it to the selection.
|
||||
*/
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.querySelector("[data-role=presentation]")
|
||||
) {
|
||||
if (target instanceof HTMLElement && isNonInteractive(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -487,7 +506,7 @@ export const DataGridRoot = <
|
||||
}, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory])
|
||||
|
||||
return (
|
||||
<div className="size-full overflow-hidden">
|
||||
<div className="bg-ui-bg-subtle size-full overflow-hidden">
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
style={{
|
||||
@@ -559,10 +578,11 @@ export const DataGridRoot = <
|
||||
data-column-index={index}
|
||||
className={clx(
|
||||
"bg-ui-bg-base has-[[data-role='presentation']]:bg-ui-bg-subtle relative flex items-center border-b border-r p-0 outline-none",
|
||||
"after:transition-fg after:border-ui-fg-interactive after:invisible after:absolute after:-bottom-px after:-left-px after:-right-px after:-top-px after:box-border after:border-[2px] after:content-['']",
|
||||
"after:transition-fg after:border-ui-fg-interactive after:pointer-events-none after:invisible after:absolute after:-bottom-px after:-left-px after:-right-px after:-top-px after:box-border after:border-[2px] after:content-['']",
|
||||
{
|
||||
"after:visible": isAnchor,
|
||||
"bg-ui-bg-highlight": isSelected,
|
||||
"bg-ui-bg-highlight focus-within:bg-ui-bg-base":
|
||||
isSelected || isAnchor,
|
||||
"bg-ui-bg-base-hover": isDragTarget,
|
||||
}
|
||||
)}
|
||||
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
import { Select } from "@medusajs/ui"
|
||||
import { Controller, FieldValues } from "react-hook-form"
|
||||
import { FieldProps } from "../../../types"
|
||||
import { CellProps } from "../../../types"
|
||||
|
||||
interface BooleanFieldProps<TFieldValues extends FieldValues = any>
|
||||
extends FieldProps<TFieldValues> {}
|
||||
interface BooleanCellProps<TFieldValues extends FieldValues = any>
|
||||
extends CellProps<TFieldValues> {}
|
||||
|
||||
export const BooleanField = <TFieldValues extends FieldValues = any>({
|
||||
export const BooleanCell = <TFieldValues extends FieldValues = any>({
|
||||
field,
|
||||
meta,
|
||||
}: BooleanFieldProps<TFieldValues>) => {
|
||||
}: BooleanCellProps<TFieldValues>) => {
|
||||
const { control } = meta
|
||||
|
||||
return (
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./boolean-cell"
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { CurrencyDTO } from "@medusajs/types"
|
||||
import { useRef } from "react"
|
||||
import Primitive from "react-currency-input-field"
|
||||
import { Controller, FieldValues } from "react-hook-form"
|
||||
|
||||
import { GridCellType } from "../../../constants"
|
||||
import { CellProps } from "../../../types"
|
||||
|
||||
interface CurrencyCellProps<TFieldValues extends FieldValues = any>
|
||||
extends CellProps<TFieldValues> {
|
||||
currency: CurrencyDTO
|
||||
}
|
||||
|
||||
export const CurrencyCell = ({ currency, field, meta }: CurrencyCellProps) => {
|
||||
const symbolRef = useRef<HTMLSpanElement>(null)
|
||||
// @ts-ignore - Type is wrong
|
||||
const decimalScale = currency.decimal_digits
|
||||
|
||||
const { control } = meta
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name={field}
|
||||
render={({ field: { onChange, ...rest } }) => {
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
<span
|
||||
ref={symbolRef}
|
||||
role="presentation"
|
||||
className="text-ui-fg-muted txt-compact-small pointer-events-none absolute left-0 top-0 select-none py-2.5 pl-4"
|
||||
>
|
||||
{currency.symbol_native}
|
||||
</span>
|
||||
<Primitive
|
||||
data-input-field="true"
|
||||
data-field-id={field}
|
||||
data-cell-type={GridCellType.EDITABLE}
|
||||
className="size-full bg-transparent py-2.5 pr-4 text-right outline-none"
|
||||
style={{
|
||||
paddingLeft: symbolRef.current?.offsetWidth
|
||||
? `${symbolRef.current.offsetWidth + 8}px`
|
||||
: "16px",
|
||||
}}
|
||||
decimalScale={decimalScale}
|
||||
allowDecimals={decimalScale > 0}
|
||||
onValueChange={(_value, _name, values) => {
|
||||
onChange(values?.value)
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./currency-cell"
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./readonly-cell"
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
import { GridCellType } from "../../../constants"
|
||||
|
||||
export const ReadonlyCell = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div
|
||||
role="cell"
|
||||
data-cell-type={GridCellType.READONLY}
|
||||
className="bg-ui-bg-base size-full cursor-not-allowed px-4 py-2.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./text-cell"
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
import { FieldValues } from "react-hook-form"
|
||||
import { FieldProps } from "../../../types"
|
||||
import { CellProps } from "../../../types"
|
||||
|
||||
interface TextFieldProps<TFieldValues extends FieldValues = any>
|
||||
extends FieldProps<TFieldValues> {}
|
||||
interface TextCellProps<TFieldValues extends FieldValues = any>
|
||||
extends CellProps<TFieldValues> {}
|
||||
|
||||
export const TextField = <TFieldValues extends FieldValues = any>({
|
||||
export const TextCell = <TFieldValues extends FieldValues = any>({
|
||||
field,
|
||||
meta,
|
||||
}: TextFieldProps<TFieldValues>) => {
|
||||
}: TextCellProps<TFieldValues>) => {
|
||||
const { register } = meta
|
||||
|
||||
return (
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./void-cell"
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
import { GridCellType } from "../../../constants"
|
||||
|
||||
export const VoidCell = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div
|
||||
role="cell"
|
||||
data-cell-type={GridCellType.VOID}
|
||||
className="bg-ui-bg-subtle size-full cursor-not-allowed px-4 py-2.5"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export * from "./boolean-field"
|
||||
-1
@@ -1 +0,0 @@
|
||||
export * from "./text-field"
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { PropsWithChildren } from "react"
|
||||
|
||||
/**
|
||||
* Field for displaying non-editable data in a grid.
|
||||
*/
|
||||
export const DisplayField = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<div
|
||||
className="flex size-full cursor-not-allowed items-center justify-center px-4 py-2.5"
|
||||
data-role="presentation"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export * from "./display-field"
|
||||
@@ -5,7 +5,7 @@ export type DataGridMeta<TFieldValues extends FieldValues = FieldValues> = {
|
||||
control: Control<TFieldValues>
|
||||
}
|
||||
|
||||
export interface FieldProps<TFieldValues extends FieldValues = FieldValues> {
|
||||
export interface CellProps<TFieldValues extends FieldValues = FieldValues> {
|
||||
field: Path<TFieldValues>
|
||||
meta: DataGridMeta<TFieldValues>
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,12 +1,17 @@
|
||||
import { Tooltip } from "@medusajs/ui"
|
||||
import format from "date-fns/format"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { PlaceholderCell } from "../placeholder-cell"
|
||||
|
||||
type DateCellProps = {
|
||||
date: Date
|
||||
date: Date | string | undefined
|
||||
}
|
||||
|
||||
export const CreatedAtCell = ({ date }: DateCellProps) => {
|
||||
if (!date) {
|
||||
return <PlaceholderCell />
|
||||
}
|
||||
|
||||
const value = new Date(date)
|
||||
value.setMinutes(value.getMinutes() - value.getTimezoneOffset())
|
||||
|
||||
|
||||
+2
-2
@@ -7,10 +7,10 @@ type StatusCellProps = PropsWithChildren<{
|
||||
|
||||
export const StatusCell = ({ color, children }: StatusCellProps) => {
|
||||
return (
|
||||
<div className="txt-compact-small text-ui-fg-subtle flex h-full w-full items-center gap-x-0.5 overflow-hidden">
|
||||
<div className="txt-compact-small text-ui-fg-subtle flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||
<div
|
||||
role="presentation"
|
||||
className="flex size-5 items-center justify-center"
|
||||
className="flex h-5 w-2 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className={clx(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
AdminCustomerGroupListResponse,
|
||||
AdminCustomerGroupResponse,
|
||||
} from "@medusajs/types"
|
||||
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
|
||||
import { client } from "../../lib/client"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import {
|
||||
AdminCustomerGroupResponse,
|
||||
AdminCustomerGroupListResponse,
|
||||
} from "@medusajs/types"
|
||||
|
||||
const CUSTOMER_GROUPS_QUERY_KEY = "customer_groups" as const
|
||||
const customerGroupsQueryKeys = queryKeysFactory(CUSTOMER_GROUPS_QUERY_KEY)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseQueryOptions,
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query"
|
||||
import { client } from "../../lib/client"
|
||||
import { queryClient } from "../../lib/medusa"
|
||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||
import {
|
||||
AddPriceListPricesReq,
|
||||
CreatePriceListReq,
|
||||
DeletePriceListPricesReq,
|
||||
UpdatePriceListReq,
|
||||
} from "../../types/api-payloads"
|
||||
import {
|
||||
PriceListDeleteRes,
|
||||
PriceListListRes,
|
||||
PriceListRes,
|
||||
} from "../../types/api-responses"
|
||||
|
||||
const PRICE_LISTS_QUERY_KEY = "price-lists" as const
|
||||
export const priceListsQueryKeys = queryKeysFactory(PRICE_LISTS_QUERY_KEY)
|
||||
|
||||
export const usePriceList = (
|
||||
id: string,
|
||||
query?: Record<string, any>,
|
||||
options?: Omit<
|
||||
UseQueryOptions<PriceListRes, Error, PriceListRes, QueryKey>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryFn: () => client.priceLists.retrieve(id, query),
|
||||
queryKey: priceListsQueryKeys.detail(id),
|
||||
...options,
|
||||
})
|
||||
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const usePriceLists = (
|
||||
query?: Record<string, any>,
|
||||
options?: Omit<
|
||||
UseQueryOptions<PriceListListRes, Error, PriceListListRes, QueryKey>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
const { data, ...rest } = useQuery({
|
||||
queryFn: () => client.priceLists.list(query),
|
||||
queryKey: priceListsQueryKeys.list(query),
|
||||
...options,
|
||||
})
|
||||
|
||||
return { ...data, ...rest }
|
||||
}
|
||||
|
||||
export const useCreatePriceList = (
|
||||
options?: UseMutationOptions<PriceListRes, Error, CreatePriceListReq>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) => client.priceLists.create(payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.list() })
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useUpdatePriceList = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<PriceListRes, Error, UpdatePriceListReq>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) => client.priceLists.update(id, payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.list() })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: priceListsQueryKeys.detail(id),
|
||||
})
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeletePriceList = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<PriceListDeleteRes, Error, void>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: () => client.priceLists.delete(id),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.list() })
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const usePriceListAddPrices = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<PriceListRes, Error, AddPriceListPricesReq>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) => client.priceLists.addPrices(id, payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: priceListsQueryKeys.detail(id),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.lists() })
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export const usePriceListRemovePrices = (
|
||||
id: string,
|
||||
options?: UseMutationOptions<PriceListRes, Error, DeletePriceListPricesReq>
|
||||
) => {
|
||||
return useMutation({
|
||||
mutationFn: (payload) => client.priceLists.removePrices(id, payload),
|
||||
onSuccess: (data, variables, context) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: priceListsQueryKeys.detail(id),
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: priceListsQueryKeys.lists() })
|
||||
|
||||
options?.onSuccess?.(data, variables, context)
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
+5
-5
@@ -1,15 +1,15 @@
|
||||
import { CustomerGroup } from "@medusajs/medusa"
|
||||
import { Text } from "@medusajs/ui"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { Text } from "@medusajs/ui"
|
||||
|
||||
import { NameHeader } from "../../../components/table/table-cells/common/name-cell"
|
||||
import { CustomerGroupDTO } from "@medusajs/types"
|
||||
import {
|
||||
CreatedAtHeader,
|
||||
CreatedAtCell,
|
||||
CreatedAtHeader,
|
||||
} from "../../../components/table/table-cells/common/created-at-cell"
|
||||
import { NameHeader } from "../../../components/table/table-cells/common/name-cell"
|
||||
|
||||
const columnHelper = createColumnHelper<CustomerGroup>()
|
||||
const columnHelper = createColumnHelper<CustomerGroupDTO>()
|
||||
|
||||
export const useCustomerGroupTableColumns = () => {
|
||||
return useMemo(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { customerGroups } from "./customer-groups"
|
||||
import { customers } from "./customers"
|
||||
import { invites } from "./invites"
|
||||
import { payments } from "./payments"
|
||||
import { priceLists } from "./price-lists"
|
||||
import { productTypes } from "./product-types"
|
||||
import { products } from "./products"
|
||||
import { promotions } from "./promotions"
|
||||
@@ -40,6 +41,7 @@ export const client = {
|
||||
invites: invites,
|
||||
products: products,
|
||||
productTypes: productTypes,
|
||||
priceLists: priceLists,
|
||||
stockLocations: stockLocations,
|
||||
workflowExecutions: workflowExecutions,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
AddPriceListPricesReq,
|
||||
CreatePriceListReq,
|
||||
DeletePriceListPricesReq,
|
||||
UpdatePriceListReq,
|
||||
} from "../../types/api-payloads"
|
||||
import {
|
||||
PriceListDeleteRes,
|
||||
PriceListListRes,
|
||||
PriceListRes,
|
||||
} from "../../types/api-responses"
|
||||
import { getRequest, postRequest } from "./common"
|
||||
|
||||
async function retrievePriceLists(id: string, query?: Record<string, any>) {
|
||||
return getRequest<PriceListRes>(`/admin/price-lists/${id}`, query)
|
||||
}
|
||||
|
||||
async function listPriceLists(query?: Record<string, any>) {
|
||||
return getRequest<PriceListListRes>(`/admin/price-lists`, query)
|
||||
}
|
||||
|
||||
async function createPriceList(payload: CreatePriceListReq) {
|
||||
return postRequest<PriceListRes>(`/admin/price-lists`, payload)
|
||||
}
|
||||
|
||||
async function updatePriceList(id: string, payload: UpdatePriceListReq) {
|
||||
return postRequest<PriceListRes>(`/admin/price-lists/${id}`, payload)
|
||||
}
|
||||
|
||||
async function deletePriceList(id: string) {
|
||||
return postRequest<PriceListDeleteRes>(`/admin/price-lists/${id}/delete`)
|
||||
}
|
||||
|
||||
async function addPriceListPrices(id: string, payload: AddPriceListPricesReq) {
|
||||
return postRequest<PriceListRes>(
|
||||
`/admin/price-lists/${id}/prices/batch/add`,
|
||||
payload
|
||||
)
|
||||
}
|
||||
|
||||
async function removePriceListPrices(
|
||||
id: string,
|
||||
payload: DeletePriceListPricesReq
|
||||
) {
|
||||
return postRequest<PriceListRes>(
|
||||
`/admin/price-lists/${id}/prices/batch/remove`,
|
||||
payload
|
||||
)
|
||||
}
|
||||
|
||||
export const priceLists = {
|
||||
retrieve: retrievePriceLists,
|
||||
list: listPriceLists,
|
||||
create: createPriceList,
|
||||
update: updatePriceList,
|
||||
delete: deletePriceList,
|
||||
addPrices: addPriceListPrices,
|
||||
removePrices: removePriceListPrices,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { currencies } from "./currencies"
|
||||
|
||||
const getDecimalDigits = (currency: string) => {
|
||||
return currencies[currency.toUpperCase()]?.decimal_digits
|
||||
export const getDecimalDigits = (currency: string) => {
|
||||
return currencies[currency.toUpperCase()]?.decimal_digits ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -18,7 +18,7 @@ const getDecimalDigits = (currency: string) => {
|
||||
export const getPresentationalAmount = (amount: number, currency: string) => {
|
||||
const decimalDigits = getDecimalDigits(currency)
|
||||
|
||||
if (!decimalDigits) {
|
||||
if (decimalDigits === undefined) {
|
||||
throw new Error("Currency has no decimal digits")
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ export const getPresentationalAmount = (amount: number, currency: string) => {
|
||||
* getDbAmount(10, "jpy") // 10
|
||||
*/
|
||||
export const getDbAmount = (amount: number, currency: string) => {
|
||||
const decimalDigits = currencies[currency.toUpperCase()].decimal_digits
|
||||
const decimalDigits = getDecimalDigits(currency)
|
||||
|
||||
if (!decimalDigits) {
|
||||
if (decimalDigits === undefined) {
|
||||
throw new Error("Currency has no decimal digits")
|
||||
}
|
||||
|
||||
|
||||
@@ -310,44 +310,6 @@ export const v1Routes: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/pricing",
|
||||
handle: {
|
||||
crumb: () => "Pricing",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () => import("../../routes/pricing/pricing-list"),
|
||||
children: [
|
||||
// {
|
||||
// path: "create",
|
||||
// lazy: () => import("../../routes/pricing/pricing-create"),
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/pricing/pricing-detail"),
|
||||
children: [
|
||||
{
|
||||
path: "edit",
|
||||
lazy: () => import("../../routes/pricing/pricing-edit"),
|
||||
},
|
||||
{
|
||||
path: "products/add",
|
||||
lazy: () =>
|
||||
import("../../routes/pricing/pricing-products-add"),
|
||||
},
|
||||
{
|
||||
path: "products/edit",
|
||||
lazy: () =>
|
||||
import("../../routes/pricing/pricing-products-edit"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...routeExtensions,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ErrorBoundary } from "../../components/error/error-boundary"
|
||||
import { MainLayout } from "../../components/layout-v2/main-layout"
|
||||
import { SettingsLayout } from "../../components/layout/settings-layout"
|
||||
import { useMe } from "../../hooks/api/users"
|
||||
import { PriceListRes } from "../../types/api-responses"
|
||||
import { SearchProvider } from "../search-provider"
|
||||
import { SidebarProvider } from "../sidebar-provider"
|
||||
|
||||
@@ -262,6 +263,53 @@ export const v2Routes: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/pricing",
|
||||
handle: {
|
||||
crumb: () => "Pricing",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () => import("../../v2-routes/pricing/pricing-list"),
|
||||
children: [
|
||||
{
|
||||
path: "create",
|
||||
lazy: () =>
|
||||
import("../../v2-routes/pricing/pricing-create"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../v2-routes/pricing/pricing-detail"),
|
||||
handle: {
|
||||
crumb: (data: PriceListRes) => data.price_list.title,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "edit",
|
||||
lazy: () => import("../../v2-routes/pricing/pricing-edit"),
|
||||
},
|
||||
{
|
||||
path: "configuration",
|
||||
lazy: () =>
|
||||
import("../../v2-routes/pricing/pricing-configuration"),
|
||||
},
|
||||
{
|
||||
path: "products/add",
|
||||
lazy: () =>
|
||||
import("../../v2-routes/pricing/pricing-products-add"),
|
||||
},
|
||||
{
|
||||
path: "products/edit",
|
||||
lazy: () =>
|
||||
import("../../v2-routes/pricing/pricing-products-prices"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/customers",
|
||||
handle: {
|
||||
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
import { Currency, Product, ProductVariant, Region } from "@medusajs/medusa"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useAdminPriceListProducts } from "medusa-react"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { z } from "zod"
|
||||
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Thumbnail } from "../../../../../components/common/thumbnail"
|
||||
import { DataGrid } from "../../../../../components/grid/data-grid"
|
||||
import { TextField } from "../../../../../components/grid/grid-fields/common/text-field"
|
||||
import { DisplayField } from "../../../../../components/grid/grid-fields/non-interactive/display-field"
|
||||
import { DataGridMeta } from "../../../../../components/grid/types"
|
||||
import { RouteFocusModal } from "../../../../../components/route-modal"
|
||||
|
||||
type EditProductPricesFormProps = {
|
||||
regions: Region[]
|
||||
currencies: Currency[]
|
||||
ids: string | null
|
||||
}
|
||||
|
||||
const ProductEditorSchema = z.object({
|
||||
products: z.record(
|
||||
z.object({
|
||||
variants: z.record(
|
||||
z.object({
|
||||
prices: z.object({
|
||||
regions: z.record(
|
||||
z.object({
|
||||
amount: z.number(),
|
||||
})
|
||||
),
|
||||
currencies: z.record(
|
||||
z.object({
|
||||
amount: z.number(),
|
||||
})
|
||||
),
|
||||
}),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
type ProductEditorSchemaType = z.infer<typeof ProductEditorSchema>
|
||||
|
||||
export const EditProductPricesForm = ({
|
||||
ids,
|
||||
regions,
|
||||
currencies,
|
||||
}: EditProductPricesFormProps) => {
|
||||
const { id } = useParams()
|
||||
|
||||
const form = useForm()
|
||||
|
||||
const { products, count, isLoading, isError, error } =
|
||||
useAdminPriceListProducts(
|
||||
id!,
|
||||
{
|
||||
id: ids?.split(",") || undefined,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const columns = useColumns({ regions, currencies })
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form className="flex size-full flex-col">
|
||||
<RouteFocusModal.Header></RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
|
||||
<DataGrid
|
||||
isLoading={isLoading}
|
||||
data={products as Product[]}
|
||||
columns={columns}
|
||||
state={form}
|
||||
getSubRows={getVariantRows}
|
||||
/>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine if a row is a product or a variant.
|
||||
*/
|
||||
const isProduct = (row: Product | ProductVariant): row is Product => {
|
||||
return "variants" in row
|
||||
}
|
||||
|
||||
const getVariantRows = (row: Product | ProductVariant) => {
|
||||
if ("variants" in row) {
|
||||
return row.variants
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Product | ProductVariant>()
|
||||
|
||||
const createRegionColum = (region: Region) => {
|
||||
return columnHelper.display({
|
||||
id: region.id,
|
||||
header: region.name,
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return <DisplayField />
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
field={`products.${entity.id}.variants.${entity.id}.prices.regions.${region.id}.amount`}
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const createCurrencyColumn = (currency: Currency) => {
|
||||
return columnHelper.display({
|
||||
id: currency.code,
|
||||
header: currency.code.toUpperCase(),
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return <DisplayField />
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
field={`products.${entity.id}.variants.${entity.id}.prices.currencies.${currency.code}.amount`}
|
||||
meta={table.options.meta as DataGridMeta<ProductEditorSchemaType>}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const useColumns = ({
|
||||
regions,
|
||||
currencies,
|
||||
}: {
|
||||
regions: Region[]
|
||||
currencies: Currency[]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const regionColumns = useMemo(() => {
|
||||
return regions.map(createRegionColum)
|
||||
}, [regions])
|
||||
|
||||
const currencyColumns = useMemo(() => {
|
||||
return currencies.map(createCurrencyColumn)
|
||||
}, [currencies])
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "product-display",
|
||||
header: t("fields.product"),
|
||||
cell: ({ row }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProduct(entity)) {
|
||||
return (
|
||||
<DisplayField>
|
||||
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||
<Thumbnail src={entity.thumbnail} />
|
||||
<span className="truncate">{entity.title}</span>
|
||||
</div>
|
||||
</DisplayField>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DisplayField>
|
||||
<div className="flex h-full w-full items-center overflow-hidden">
|
||||
<span className="truncate">{entity.title}</span>
|
||||
</div>
|
||||
</DisplayField>
|
||||
)
|
||||
},
|
||||
size: 350,
|
||||
}),
|
||||
...regionColumns,
|
||||
...currencyColumns,
|
||||
],
|
||||
[t, regionColumns, currencyColumns]
|
||||
)
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
export * from "./edit-product-prices-form"
|
||||
@@ -1 +0,0 @@
|
||||
export { PricingProductsEdit as Component } from "./pricing-products-edit"
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import { useAdminRegions, useAdminStore } from "medusa-react"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { EditProductPricesForm } from "./components/edit-product-prices-form"
|
||||
|
||||
export const PricingProductsEdit = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const { regions, isLoading, isError, error } = useAdminRegions({
|
||||
limit: 1000,
|
||||
fields: "id,name,includes_tax,currency.code,currency.symbol_native",
|
||||
expand: "currency",
|
||||
})
|
||||
|
||||
const {
|
||||
store,
|
||||
isLoading: isLoadingStore,
|
||||
isError: isStoreError,
|
||||
error: storeError,
|
||||
} = useAdminStore()
|
||||
|
||||
const ids = searchParams.get("ids[]")
|
||||
const currencies = store?.currencies || []
|
||||
const ready = !isLoading && regions && !isLoadingStore && store
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (isStoreError) {
|
||||
throw storeError
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{ready && (
|
||||
<EditProductPricesForm
|
||||
ids={ids}
|
||||
regions={regions}
|
||||
currencies={currencies}
|
||||
/>
|
||||
)}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CreateCampaignDTO,
|
||||
CreateCustomerDTO,
|
||||
CreateInviteDTO,
|
||||
CreatePriceListDTO,
|
||||
CreateProductCollectionDTO,
|
||||
CreatePromotionDTO,
|
||||
CreatePromotionRuleDTO,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
UpdateApiKeyDTO,
|
||||
UpdateCampaignDTO,
|
||||
UpdateCustomerDTO,
|
||||
UpdatePriceListDTO,
|
||||
UpdateProductCollectionDTO,
|
||||
UpdatePromotionDTO,
|
||||
UpdatePromotionRuleDTO,
|
||||
@@ -64,6 +66,18 @@ export type UpdateStockLocationReq = UpdateStockLocationInput
|
||||
export type CreateProductCollectionReq = CreateProductCollectionDTO
|
||||
export type UpdateProductCollectionReq = UpdateProductCollectionDTO
|
||||
|
||||
// Price Lists
|
||||
export type CreatePriceListReq = CreatePriceListDTO
|
||||
export type UpdatePriceListReq = UpdatePriceListDTO
|
||||
export type AddPriceListPricesReq = {
|
||||
prices: {
|
||||
currency_code: string
|
||||
amount: number
|
||||
variant_id: string
|
||||
}[]
|
||||
}
|
||||
export type DeletePriceListPricesReq = { ids: string[] }
|
||||
|
||||
// Promotion
|
||||
export type CreatePromotionReq = CreatePromotionDTO
|
||||
export type UpdatePromotionReq = UpdatePromotionDTO
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
import {
|
||||
CampaignDTO,
|
||||
CurrencyDTO,
|
||||
CustomerGroupDTO,
|
||||
InviteDTO,
|
||||
PaymentProviderDTO,
|
||||
PriceListDTO,
|
||||
ProductCategoryDTO,
|
||||
ProductCollectionDTO,
|
||||
ProductDTO,
|
||||
@@ -137,3 +139,15 @@ export type ProductCollectionListRes = {
|
||||
collections: ProductCollectionDTO[]
|
||||
} & ListRes
|
||||
export type ProductCollectionDeleteRes = DeleteRes
|
||||
|
||||
// Price Lists
|
||||
export type PriceListRes = { price_list: PriceListDTO }
|
||||
export type PriceListListRes = { price_lists: PriceListDTO[] } & ListRes
|
||||
export type PriceListDeleteRes = DeleteRes
|
||||
|
||||
// Customer Groups
|
||||
export type CustomerGroupRes = { customer_group: CustomerGroupDTO }
|
||||
export type CustomerGroupListRes = {
|
||||
customer_groups: CustomerGroupDTO[]
|
||||
} & ListRes
|
||||
export type CustomerGroupDeleteRes = DeleteRes
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { CurrencyDTO, ProductVariantDTO } from "@medusajs/types"
|
||||
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Thumbnail } from "../../../../components/common/thumbnail"
|
||||
import { CurrencyCell } from "../../../../components/grid/grid-cells/common/currency-cell"
|
||||
import { ReadonlyCell } from "../../../../components/grid/grid-cells/common/readonly-cell"
|
||||
import { VoidCell } from "../../../../components/grid/grid-cells/common/void-cell"
|
||||
import { DataGridMeta } from "../../../../components/grid/types"
|
||||
import { ExtendedProductDTO } from "../../../../types/api-responses"
|
||||
import { isProductRow } from "../utils"
|
||||
|
||||
const columnHelper = createColumnHelper<
|
||||
ExtendedProductDTO | ProductVariantDTO
|
||||
>()
|
||||
|
||||
export const usePriceListGridColumns = ({
|
||||
currencies = [],
|
||||
}: {
|
||||
currencies?: CurrencyDTO[]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const colDefs: ColumnDef<ExtendedProductDTO | ProductVariantDTO>[] =
|
||||
useMemo(() => {
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: t("fields.title"),
|
||||
header: t("fields.title"),
|
||||
cell: ({ row }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProductRow(entity)) {
|
||||
return (
|
||||
<VoidCell>
|
||||
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||
<Thumbnail src={entity.thumbnail} />
|
||||
<span className="truncate">{entity.title}</span>
|
||||
</div>
|
||||
</VoidCell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReadonlyCell>
|
||||
<div className="flex h-full w-full items-center gap-x-2 overflow-hidden">
|
||||
<span className="truncate">{entity.title}</span>
|
||||
</div>
|
||||
</ReadonlyCell>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...currencies.map((currency) => {
|
||||
return columnHelper.display({
|
||||
header: `Price ${currency.code.toUpperCase()}`,
|
||||
cell: ({ row, table }) => {
|
||||
const entity = row.original
|
||||
|
||||
if (isProductRow(entity)) {
|
||||
return <VoidCell />
|
||||
}
|
||||
|
||||
return (
|
||||
<CurrencyCell
|
||||
currency={currency}
|
||||
meta={table.options.meta as DataGridMeta}
|
||||
field={`products.${entity.product_id}.variants.${entity.id}.currency_prices.${currency.code}.amount`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
}),
|
||||
]
|
||||
}, [t, currencies])
|
||||
|
||||
return colDefs
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const PricingVariantPricesRecordSchema = z.record(
|
||||
z
|
||||
.object({
|
||||
amount: z.string().optional(),
|
||||
id: z.string().nullable().optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
|
||||
const PricingVariantsRecordSchema = z.record(
|
||||
z.object({
|
||||
currency_prices: PricingVariantPricesRecordSchema,
|
||||
region_prices: PricingVariantPricesRecordSchema,
|
||||
})
|
||||
)
|
||||
|
||||
export type PricingVariantsRecordType = z.infer<
|
||||
typeof PricingVariantsRecordSchema
|
||||
>
|
||||
|
||||
export const PricingProductsRecordSchema = z.record(
|
||||
z.object({
|
||||
variants: PricingVariantsRecordSchema,
|
||||
})
|
||||
)
|
||||
|
||||
export type PricingProductsRecordType = z.infer<
|
||||
typeof PricingProductsRecordSchema
|
||||
>
|
||||
+10
-3
@@ -1,8 +1,9 @@
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import { PriceListDTO, ProductVariantDTO } from "@medusajs/types"
|
||||
import { TFunction } from "i18next"
|
||||
import { ExtendedProductDTO } from "../../../types/api-responses"
|
||||
import { PriceListStatus } from "./constants"
|
||||
|
||||
const getValues = (priceList: PriceList) => {
|
||||
const getValues = (priceList: PriceListDTO) => {
|
||||
const startsAt = priceList.starts_at
|
||||
const endsAt = priceList.ends_at
|
||||
|
||||
@@ -19,7 +20,7 @@ const getValues = (priceList: PriceList) => {
|
||||
|
||||
export const getPriceListStatus = (
|
||||
t: TFunction<"translation">,
|
||||
priceList: PriceList
|
||||
priceList: PriceListDTO
|
||||
) => {
|
||||
const { isExpired, isScheduled, isDraft } = getValues(priceList)
|
||||
|
||||
@@ -46,3 +47,9 @@ export const getPriceListStatus = (
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
export const isProductRow = (
|
||||
row: ExtendedProductDTO | ProductVariantDTO
|
||||
): row is ExtendedProductDTO => {
|
||||
return "variants" in row
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./price-list-configuration-form"
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { PriceListDTO } from "@medusajs/types"
|
||||
import { Button, DatePicker, Switch, Text } from "@medusajs/ui"
|
||||
import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
import { Divider } from "../../../../../components/common/divider"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdatePriceList } from "../../../../../hooks/api/price-lists"
|
||||
|
||||
type PriceListConfigurationFormProps = {
|
||||
priceList: PriceListDTO
|
||||
}
|
||||
|
||||
const PriceListConfigurationSchema = z.object({
|
||||
ends_at: z.date().nullable(),
|
||||
starts_at: z.date().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* TODO: Add CustomerGroups and possibly change to a RouteFocusModal
|
||||
*
|
||||
* Customer group rules aren't supported out of the box atm, so we can't
|
||||
* set them in the UI without throwing an error.
|
||||
*/
|
||||
|
||||
export const PriceListConfigurationForm = ({
|
||||
priceList,
|
||||
}: PriceListConfigurationFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<z.infer<typeof PriceListConfigurationSchema>>({
|
||||
defaultValues: {
|
||||
ends_at: priceList.ends_at ? new Date(priceList.ends_at) : null,
|
||||
starts_at: priceList.starts_at ? new Date(priceList.starts_at) : null,
|
||||
},
|
||||
resolver: zodResolver(PriceListConfigurationSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync } = useUpdatePriceList(priceList.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await mutateAsync(
|
||||
// @ts-ignore - type is wrong and expects an ID.
|
||||
{
|
||||
starts_at: values.starts_at?.toISOString() || null,
|
||||
ends_at: values.ends_at?.toISOString() || null,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleSuccess()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<RouteDrawer.Form form={form}>
|
||||
<form
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<RouteDrawer.Body className="flex flex-1 flex-col gap-y-8 overflow-auto">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="starts_at"
|
||||
render={({ field: { value, onChange, ...rest } }) => {
|
||||
const handleSwitchChange = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
onChange(now)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item>
|
||||
<Collapsible.Root
|
||||
open={!!value}
|
||||
onOpenChange={handleSwitchChange}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_32px] gap-4">
|
||||
<div>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
Price list has a start date?
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Schedule the price list to activate in the future.
|
||||
</Text>
|
||||
</div>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Switch name={rest.name} checked={!!value} />
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<div className="flex flex-col gap-y-2 pt-4">
|
||||
<Form.Label className="!txt-small text-ui-fg-subtle">
|
||||
{t("fields.startDate")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<DatePicker
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
</Form.Control>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
<Form.ErrorMessage />
|
||||
</Collapsible.Root>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="ends_at"
|
||||
render={({ field: { value, onChange, ...rest } }) => {
|
||||
const handleSwitchChange = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
const inAWeek = new Date(
|
||||
new Date().setDate(new Date().getDate() + 7)
|
||||
)
|
||||
|
||||
onChange(inAWeek)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item>
|
||||
<Collapsible.Root
|
||||
open={!!value}
|
||||
onOpenChange={handleSwitchChange}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_32px] gap-4">
|
||||
<div>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
Price list has an end date?
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Schedule the price list to deactivate in the future.
|
||||
</Text>
|
||||
</div>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Switch name={rest.name} checked={!!value} />
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<div className="flex flex-col gap-y-2 pt-4">
|
||||
<Form.Label className="!txt-small text-ui-fg-subtle">
|
||||
{t("fields.endDate")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<DatePicker
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
</Form.Control>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
<Form.ErrorMessage />
|
||||
</Collapsible.Root>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer className="shrink-0">
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteDrawer.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteDrawer.Close>
|
||||
<Button size="small" type="submit">
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteDrawer.Footer>
|
||||
</form>
|
||||
</RouteDrawer.Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingConfiguration as Component } from "./pricing-configuration"
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteDrawer } from "../../../components/route-modal"
|
||||
import { usePriceList } from "../../../hooks/api/price-lists"
|
||||
import { PriceListConfigurationForm } from "./components/price-list-configuration-form"
|
||||
|
||||
export const PricingConfiguration = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
|
||||
const { price_list, isLoading, isError, error } = usePriceList(id!)
|
||||
|
||||
const ready = !isLoading && price_list
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>{t("pricing.settings.editPriceListTitle")}</Heading>
|
||||
</RouteDrawer.Header>
|
||||
{ready && <PriceListConfigurationForm priceList={price_list} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./pricing-create-form"
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { Button, ProgressStatus, ProgressTabs } from "@medusajs/ui"
|
||||
import { FieldPath, useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { CreatePriceListDTO, CreatePriceListPriceDTO } from "@medusajs/types"
|
||||
import { useState } from "react"
|
||||
import { z } from "zod"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useCreatePriceList } from "../../../../../hooks/api/price-lists"
|
||||
import { castNumber } from "../../../../../lib/cast-number"
|
||||
import { getDbAmount } from "../../../../../lib/money-amount-helpers"
|
||||
import { PricingDetailsForm } from "./pricing-details-form"
|
||||
import { PricingPricesForm } from "./pricing-prices-form"
|
||||
import { PricingProductsForm } from "./pricing-products-form"
|
||||
import {
|
||||
PricingCreateSchema,
|
||||
PricingCreateSchemaType,
|
||||
PricingDetailsFields,
|
||||
PricingDetailsSchema,
|
||||
PricingPricesFields,
|
||||
PricingProductsFields,
|
||||
PricingProductsSchema,
|
||||
} from "./schema"
|
||||
|
||||
enum Tab {
|
||||
DETAIL = "detail",
|
||||
PRODUCT = "product",
|
||||
PRICE = "price",
|
||||
}
|
||||
|
||||
const tabOrder = [Tab.DETAIL, Tab.PRODUCT, Tab.PRICE] as const
|
||||
|
||||
type TabState = Record<Tab, ProgressStatus>
|
||||
|
||||
const initialTabState: TabState = {
|
||||
[Tab.DETAIL]: "in-progress",
|
||||
[Tab.PRODUCT]: "not-started",
|
||||
[Tab.PRICE]: "not-started",
|
||||
}
|
||||
|
||||
export const PricingCreateForm = () => {
|
||||
const [tab, setTab] = useState<Tab>(Tab.DETAIL)
|
||||
const [tabState, setTabState] = useState<TabState>(initialTabState)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<PricingCreateSchemaType>({
|
||||
defaultValues: {
|
||||
type: "sale",
|
||||
title: "",
|
||||
description: "",
|
||||
starts_at: null,
|
||||
ends_at: null,
|
||||
customer_group_ids: [],
|
||||
product_ids: [],
|
||||
products: {},
|
||||
},
|
||||
resolver: zodResolver(PricingCreateSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync, isPending } = useCreatePriceList()
|
||||
|
||||
const handleSubmit = form.handleSubmit(
|
||||
async (data) => {
|
||||
const { customer_group_ids, products } = data
|
||||
|
||||
const rules = customer_group_ids?.length
|
||||
? { customer_group_id: customer_group_ids.map((cg) => cg.id) }
|
||||
: undefined
|
||||
|
||||
const prices: CreatePriceListPriceDTO[] = []
|
||||
|
||||
for (const [_, product] of Object.entries(products)) {
|
||||
const { variants } = product
|
||||
|
||||
for (const [variantId, variant] of Object.entries(variants)) {
|
||||
const { currency_prices } = variant
|
||||
|
||||
for (const [currencyCode, currencyPrice] of Object.entries(
|
||||
currency_prices
|
||||
)) {
|
||||
if (!currencyPrice) {
|
||||
continue
|
||||
}
|
||||
|
||||
prices.push({
|
||||
amount: getDbAmount(
|
||||
castNumber(currencyPrice.amount),
|
||||
currencyCode
|
||||
),
|
||||
currency_code: currencyCode,
|
||||
// @ts-expect-error type is wrong
|
||||
variant_id: variantId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
title: data.title,
|
||||
type: data.type as CreatePriceListDTO["type"],
|
||||
description: data.description,
|
||||
starts_at: data.starts_at ? data.starts_at.toISOString() : null,
|
||||
ends_at: data.ends_at ? data.ends_at.toISOString() : null,
|
||||
rules,
|
||||
prices: prices,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ price_list }) => {
|
||||
handleSuccess(`../${price_list.id}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
(error) => console.error(error)
|
||||
)
|
||||
|
||||
const partialFormValidation = (
|
||||
fields: FieldPath<PricingCreateSchemaType>[],
|
||||
schema: z.ZodSchema<any>
|
||||
) => {
|
||||
form.clearErrors(fields)
|
||||
|
||||
const values = fields.reduce((acc, key) => {
|
||||
acc[key] = form.getValues(key)
|
||||
return acc
|
||||
}, {} as Record<string, unknown>)
|
||||
|
||||
const validationResult = schema.safeParse(values)
|
||||
|
||||
if (!validationResult.success) {
|
||||
validationResult.error.errors.forEach(({ path, message }) => {
|
||||
form.setError(path.join(".") as keyof PricingCreateSchemaType, {
|
||||
type: "manual",
|
||||
message,
|
||||
})
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const isTabDirty = (tab: Tab) => {
|
||||
switch (tab) {
|
||||
case Tab.DETAIL: {
|
||||
const fields = PricingDetailsFields
|
||||
|
||||
return fields.some((field) => {
|
||||
return form.getFieldState(field).isDirty
|
||||
})
|
||||
}
|
||||
case Tab.PRODUCT: {
|
||||
const fields = PricingProductsFields
|
||||
|
||||
return fields.some((field) => {
|
||||
return form.getFieldState(field).isDirty
|
||||
})
|
||||
}
|
||||
case Tab.PRICE: {
|
||||
const fields = PricingPricesFields
|
||||
|
||||
return fields.some((field) => {
|
||||
return form.getFieldState(field).isDirty
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeTab = (update: Tab) => {
|
||||
if (tab === update) {
|
||||
return
|
||||
}
|
||||
|
||||
if (tabOrder.indexOf(update) < tabOrder.indexOf(tab)) {
|
||||
const isCurrentTabDirty = isTabDirty(tab)
|
||||
|
||||
setTabState((prev) => ({
|
||||
...prev,
|
||||
[tab]: isCurrentTabDirty ? prev[tab] : "not-started",
|
||||
[update]: "in-progress",
|
||||
}))
|
||||
|
||||
setTab(update)
|
||||
return
|
||||
}
|
||||
|
||||
// get the tabs from the current tab to the update tab including the current tab
|
||||
const tabs = tabOrder.slice(0, tabOrder.indexOf(update))
|
||||
|
||||
// validate all the tabs from the current tab to the update tab if it fails on any of tabs then set that tab as current tab
|
||||
for (const tab of tabs) {
|
||||
if (tab === Tab.DETAIL) {
|
||||
if (
|
||||
!partialFormValidation(PricingDetailsFields, PricingDetailsSchema)
|
||||
) {
|
||||
setTabState((prev) => ({
|
||||
...prev,
|
||||
[tab]: "in-progress",
|
||||
}))
|
||||
setTab(tab)
|
||||
return
|
||||
}
|
||||
|
||||
setTabState((prev) => ({
|
||||
...prev,
|
||||
[tab]: "completed",
|
||||
}))
|
||||
} else if (tab === Tab.PRODUCT) {
|
||||
if (
|
||||
!partialFormValidation(PricingProductsFields, PricingProductsSchema)
|
||||
) {
|
||||
setTabState((prev) => ({
|
||||
...prev,
|
||||
[tab]: "in-progress",
|
||||
}))
|
||||
setTab(tab)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setTabState((prev) => ({
|
||||
...prev,
|
||||
[tab]: "completed",
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
setTabState((prev) => ({
|
||||
...prev,
|
||||
[tab]: "completed",
|
||||
[update]: "in-progress",
|
||||
}))
|
||||
setTab(update)
|
||||
}
|
||||
|
||||
const handleNextTab = (tab: Tab) => {
|
||||
if (tabOrder.indexOf(tab) + 1 >= tabOrder.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextTab = tabOrder[tabOrder.indexOf(tab) + 1]
|
||||
handleChangeTab(nextTab)
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<ProgressTabs
|
||||
value={tab}
|
||||
onValueChange={(tab) => handleChangeTab(tab as Tab)}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="flex h-full flex-col">
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex w-full items-center justify-between gap-x-4">
|
||||
<div className="-my-2 w-full max-w-[400px] border-l">
|
||||
<ProgressTabs.List className="grid w-full grid-cols-3">
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState.detail}
|
||||
value={Tab.DETAIL}
|
||||
>
|
||||
Details
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState.product}
|
||||
value={Tab.PRODUCT}
|
||||
>
|
||||
Products
|
||||
</ProgressTabs.Trigger>
|
||||
<ProgressTabs.Trigger
|
||||
status={tabState.price}
|
||||
value={Tab.PRICE}
|
||||
>
|
||||
Prices
|
||||
</ProgressTabs.Trigger>
|
||||
</ProgressTabs.List>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<PrimaryButton
|
||||
tab={tab}
|
||||
next={handleNextTab}
|
||||
isLoading={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="size-full overflow-hidden">
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.DETAIL}
|
||||
>
|
||||
<PricingDetailsForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-y-auto"
|
||||
value={Tab.PRODUCT}
|
||||
>
|
||||
<PricingProductsForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
<ProgressTabs.Content
|
||||
className="size-full overflow-hidden"
|
||||
value={Tab.PRICE}
|
||||
>
|
||||
<PricingPricesForm form={form} />
|
||||
</ProgressTabs.Content>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</ProgressTabs>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
type PrimaryButtonProps = {
|
||||
tab: Tab
|
||||
next: (tab: Tab) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const PrimaryButton = ({ tab, next, isLoading }: PrimaryButtonProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (tab === Tab.PRICE) {
|
||||
return (
|
||||
<Button
|
||||
key="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="small"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key="next-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => next(tab)}
|
||||
>
|
||||
{t("actions.continue")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+536
@@ -0,0 +1,536 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Heading,
|
||||
Input,
|
||||
RadioGroup,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@medusajs/ui"
|
||||
import * as Collapsible from "@radix-ui/react-collapsible"
|
||||
import { useFieldArray, type UseFormReturn } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { XMarkMini } from "@medusajs/icons"
|
||||
import { CustomerGroupDTO } from "@medusajs/types"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import {
|
||||
OnChangeFn,
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table"
|
||||
import { t } from "i18next"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Divider } from "../../../../../components/common/divider"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { SplitView } from "../../../../../components/layout/split-view"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useCustomerGroups } from "../../../../../hooks/api/customer-groups"
|
||||
import { useCustomerGroupTableColumns } from "../../../../../hooks/table/columns/use-customer-group-table-columns"
|
||||
import { useCustomerGroupTableQuery } from "../../../../../hooks/table/query/use-customer-group-table-query"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import type {
|
||||
PricingCreateSchemaType,
|
||||
PricingCustomerGroupsArrayType,
|
||||
} from "./schema"
|
||||
|
||||
type PricingDetailsFormProps = {
|
||||
form: UseFormReturn<PricingCreateSchemaType>
|
||||
}
|
||||
|
||||
export const PricingDetailsForm = ({ form }: PricingDetailsFormProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showCustomerGroups, setShowCustomerGroups] = useState(
|
||||
!!form.getValues("customer_group_ids")?.length
|
||||
)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { fields, remove, append } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "customer_group_ids",
|
||||
keyName: "cg_id",
|
||||
})
|
||||
|
||||
const handleAddCustomerGroup = (groups: PricingCustomerGroupsArrayType) => {
|
||||
const newIds = groups.map((group) => group.id)
|
||||
|
||||
const fieldsToAdd = groups.filter(
|
||||
(group) => !fields.some((field) => field.id === group.id)
|
||||
)
|
||||
|
||||
for (const field of fields) {
|
||||
if (!newIds.includes(field.id)) {
|
||||
remove(fields.indexOf(field))
|
||||
}
|
||||
}
|
||||
|
||||
append(fieldsToAdd)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleOpenDrawer = () => {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleShowCustomerGroups = (open: boolean) => {
|
||||
if (!open) {
|
||||
form.setValue("customer_group_ids", [])
|
||||
}
|
||||
|
||||
setShowCustomerGroups(open)
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitView open={open} onOpenChange={setOpen}>
|
||||
<SplitView.Content>
|
||||
<div className="flex flex-1 flex-col items-center overflow-y-auto">
|
||||
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-2 py-16">
|
||||
<div>
|
||||
<Heading>Create Price List</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Create a new price list to manage the prices of your products.
|
||||
</Text>
|
||||
</div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field: { onChange, ...rest } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Form.Label>{t("fields.type")}</Form.Label>
|
||||
<Form.Hint>
|
||||
Choose the type of price list you want to create.
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<RadioGroup
|
||||
onValueChange={onChange}
|
||||
{...rest}
|
||||
className="grid grid-cols-1 gap-4 md:grid-cols-2"
|
||||
>
|
||||
<RadioGroup.ChoiceBox
|
||||
value={"sale"}
|
||||
label="Sale"
|
||||
description="Choose this if you are creating a sale"
|
||||
/>
|
||||
<RadioGroup.ChoiceBox
|
||||
value={"override"}
|
||||
label="Override"
|
||||
description="Choose this if you are creating an override"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Form.Control>
|
||||
</div>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.title")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.description")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Textarea {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="starts_at"
|
||||
render={({ field: { value, onChange, ...rest } }) => {
|
||||
const handleSwitchChange = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
onChange(now)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item>
|
||||
<Collapsible.Root
|
||||
open={!!value}
|
||||
onOpenChange={handleSwitchChange}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_32px] gap-4">
|
||||
<div>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
Price list has a start date?
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Schedule the price list to activate in the future.
|
||||
</Text>
|
||||
</div>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Switch name={rest.name} checked={!!value} />
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<div className="flex flex-col gap-y-2 pt-4">
|
||||
<Form.Label className="!txt-small text-ui-fg-subtle">
|
||||
{t("fields.startDate")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<DatePicker
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
</Form.Control>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
<Form.ErrorMessage />
|
||||
</Collapsible.Root>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="ends_at"
|
||||
render={({ field: { value, onChange, ...rest } }) => {
|
||||
const handleSwitchChange = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
const inAWeek = new Date(
|
||||
new Date().setDate(new Date().getDate() + 7)
|
||||
)
|
||||
|
||||
onChange(inAWeek)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item>
|
||||
<Collapsible.Root
|
||||
open={!!value}
|
||||
onOpenChange={handleSwitchChange}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_32px] gap-4">
|
||||
<div>
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
Price list has an end date?
|
||||
</Text>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Schedule the price list to deactivate in the future.
|
||||
</Text>
|
||||
</div>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Switch name={rest.name} checked={!!value} />
|
||||
</Collapsible.Trigger>
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<div className="flex flex-col gap-y-2 pt-4">
|
||||
<Form.Label className="!txt-small text-ui-fg-subtle">
|
||||
{t("fields.endDate")}
|
||||
</Form.Label>
|
||||
<Form.Control>
|
||||
<DatePicker
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
</Form.Control>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
<Form.ErrorMessage />
|
||||
</Collapsible.Root>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Divider />
|
||||
<div>
|
||||
<Collapsible.Root
|
||||
open={showCustomerGroups}
|
||||
onOpenChange={handleShowCustomerGroups}
|
||||
>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="customer_group_ids"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div className="grid grid-cols-[1fr_32px] items-start gap-4">
|
||||
<div>
|
||||
<Form.Label optional>
|
||||
Customer availability
|
||||
</Form.Label>
|
||||
<Form.Hint>
|
||||
Specify which customer groups the price overrides
|
||||
should apply for.
|
||||
</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Switch
|
||||
name={field.name}
|
||||
checked={showCustomerGroups}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
</Form.Control>
|
||||
</div>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Collapsible.Content>
|
||||
<div className="flex flex-col pt-4">
|
||||
{fields.length > 0 ? (
|
||||
fields.map((field, index) => {
|
||||
return (
|
||||
<div
|
||||
key={field.cg_id}
|
||||
className="bg-ui-bg-field shadow-borders-base transition-fg hover:bg-ui-bg-field-hover flex h-7 w-fit items-center overflow-hidden rounded-md"
|
||||
>
|
||||
<div className="txt-compact-small-plus flex h-full select-none items-center justify-center px-2 py-0.5">
|
||||
{field.name}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="focus-visible:bg-ui-bg-field-hover transition-fg hover:bg-ui-bg-field-hover flex h-full w-7 items-center justify-center border-l outline-none"
|
||||
>
|
||||
<XMarkMini className="text-ui-fg-muted" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="flex items-center justify-center px-2 py-3">
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className="text-ui-fg-muted"
|
||||
>
|
||||
No customer groups selected.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={handleOpenDrawer}
|
||||
>
|
||||
Add customer groups
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SplitView.Content>
|
||||
<CustomerGroupDrawer
|
||||
selectedCustomerGroups={fields}
|
||||
saveCustomerGroups={handleAddCustomerGroup}
|
||||
/>
|
||||
</SplitView>
|
||||
)
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const PREFIX = "cg"
|
||||
|
||||
const initRowSelection = (
|
||||
selectedCustomerGroups: PricingCustomerGroupsArrayType
|
||||
) => {
|
||||
return selectedCustomerGroups.reduce((acc, group) => {
|
||||
acc[group.id] = true
|
||||
return acc
|
||||
}, {} as RowSelectionState)
|
||||
}
|
||||
|
||||
const CustomerGroupDrawer = ({
|
||||
selectedCustomerGroups,
|
||||
saveCustomerGroups,
|
||||
}: {
|
||||
selectedCustomerGroups: PricingCustomerGroupsArrayType
|
||||
saveCustomerGroups: (arr: PricingCustomerGroupsArrayType) => void
|
||||
}) => {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
|
||||
initRowSelection(selectedCustomerGroups)
|
||||
)
|
||||
const [intermediate, setIntermediate] =
|
||||
useState<PricingCustomerGroupsArrayType>(selectedCustomerGroups)
|
||||
|
||||
useEffect(() => {
|
||||
// If the selected customer groups change outside of the drawer,
|
||||
// update the row selection state and intermediate state
|
||||
setRowSelection(initRowSelection(selectedCustomerGroups))
|
||||
setIntermediate(selectedCustomerGroups)
|
||||
}, [selectedCustomerGroups])
|
||||
|
||||
const { searchParams, raw } = useCustomerGroupTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
})
|
||||
const { customer_groups, count, isLoading, isError, error } =
|
||||
useCustomerGroups(searchParams, {
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
|
||||
const updater: OnChangeFn<RowSelectionState> = (value) => {
|
||||
const state = typeof value === "function" ? value(rowSelection) : value
|
||||
const currentIds = Object.keys(rowSelection)
|
||||
|
||||
const ids = Object.keys(state)
|
||||
|
||||
const newIds = ids.filter((id) => !currentIds.includes(id))
|
||||
const removedIds = currentIds.filter((id) => !ids.includes(id))
|
||||
|
||||
const newCustomerGroups =
|
||||
customer_groups
|
||||
?.filter((cg) => newIds.includes(cg.id))
|
||||
.map((cg) => ({ id: cg.id, name: cg.name })) || []
|
||||
|
||||
const filteredIntermediate = intermediate.filter(
|
||||
(cg) => !removedIds.includes(cg.id)
|
||||
)
|
||||
|
||||
setIntermediate([...filteredIntermediate, ...newCustomerGroups])
|
||||
setRowSelection(state)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
saveCustomerGroups(intermediate)
|
||||
}
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: customer_groups || [],
|
||||
columns,
|
||||
count,
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
getRowId: (row) => row.id,
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
updater,
|
||||
},
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitView.Drawer>
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
pageSize={PAGE_SIZE}
|
||||
count={count}
|
||||
isLoading={isLoading}
|
||||
layout="fill"
|
||||
pagination
|
||||
search
|
||||
prefix={PREFIX}
|
||||
queryObject={raw}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2 border-t p-4">
|
||||
<SplitView.Close type="button" asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</SplitView.Close>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("actions.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SplitView.Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<CustomerGroupDTO>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = useCustomerGroupTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[base]
|
||||
)
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { useEffect } from "react"
|
||||
import { UseFormReturn, useWatch } from "react-hook-form"
|
||||
import { DataGrid } from "../../../../../components/grid/data-grid"
|
||||
import { useCurrencies } from "../../../../../hooks/api/currencies"
|
||||
import { useProducts } from "../../../../../hooks/api/products"
|
||||
import { useStore } from "../../../../../hooks/api/store"
|
||||
import { usePriceListGridColumns } from "../../../common/hooks/use-price-list-grid-columns"
|
||||
import { PricingVariantsRecordType } from "../../../common/schemas"
|
||||
import { isProductRow } from "../../../common/utils"
|
||||
import { PricingCreateSchemaType } from "./schema"
|
||||
|
||||
type PricingPricesFormProps = {
|
||||
form: UseFormReturn<PricingCreateSchemaType>
|
||||
}
|
||||
|
||||
export const PricingPricesForm = ({ form }: PricingPricesFormProps) => {
|
||||
const {
|
||||
store,
|
||||
isLoading: isStoreLoading,
|
||||
isError: isStoreError,
|
||||
error: storeError,
|
||||
} = useStore()
|
||||
|
||||
const {
|
||||
currencies,
|
||||
isLoading: isCurrenciesLoading,
|
||||
isError: isCurrencyError,
|
||||
error: currencyError,
|
||||
} = useCurrencies(
|
||||
{
|
||||
code: store?.supported_currency_codes,
|
||||
limit: store?.supported_currency_codes?.length,
|
||||
},
|
||||
{
|
||||
enabled: !!store,
|
||||
}
|
||||
)
|
||||
|
||||
const ids = useWatch({
|
||||
control: form.control,
|
||||
name: "product_ids",
|
||||
})
|
||||
|
||||
const existingProducts = useWatch({
|
||||
control: form.control,
|
||||
name: "products",
|
||||
})
|
||||
|
||||
const { products, isLoading, isError, error } = useProducts({
|
||||
id: ids.map((id) => id.id),
|
||||
limit: ids.length,
|
||||
fields: "title,thumbnail,*variants",
|
||||
})
|
||||
|
||||
const { setValue } = form
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && products) {
|
||||
products.forEach((product) => {
|
||||
/**
|
||||
* If the product already exists in the form, we don't want to overwrite it.
|
||||
*/
|
||||
if (existingProducts[product.id] || !product.variants) {
|
||||
return
|
||||
}
|
||||
|
||||
setValue(`products.${product.id}.variants`, {
|
||||
...product.variants.reduce((variants, variant) => {
|
||||
variants[variant.id] = {
|
||||
currency_prices: {},
|
||||
region_prices: {},
|
||||
}
|
||||
return variants
|
||||
}, {} as PricingVariantsRecordType),
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [products, existingProducts, isLoading, setValue])
|
||||
|
||||
const columns = usePriceListGridColumns({
|
||||
currencies,
|
||||
})
|
||||
|
||||
const initializing =
|
||||
isLoading ||
|
||||
isStoreLoading ||
|
||||
isCurrenciesLoading ||
|
||||
!products ||
|
||||
!store ||
|
||||
!currencies
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (isStoreError) {
|
||||
throw storeError
|
||||
}
|
||||
|
||||
if (isCurrencyError) {
|
||||
throw currencyError
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col divide-y overflow-hidden">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={products}
|
||||
getSubRows={(row) => {
|
||||
if (isProductRow(row)) {
|
||||
return row.variants
|
||||
}
|
||||
}}
|
||||
isLoading={initializing}
|
||||
state={form}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { Checkbox } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import {
|
||||
OnChangeFn,
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table"
|
||||
import { useMemo, useState } from "react"
|
||||
import { UseFormReturn, useWatch } from "react-hook-form"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useProducts } from "../../../../../hooks/api/products"
|
||||
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
|
||||
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
|
||||
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { ExtendedProductDTO } from "../../../../../types/api-responses"
|
||||
import { PricingCreateSchemaType, PricingProductsRecordType } from "./schema"
|
||||
|
||||
type PricingProductsFormProps = {
|
||||
form: UseFormReturn<PricingCreateSchemaType>
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const PREFIX = "p"
|
||||
|
||||
function getInitialSelection(products: { id: string }[]) {
|
||||
return products.reduce((acc, curr) => {
|
||||
acc[curr.id] = true
|
||||
return acc
|
||||
}, {} as RowSelectionState)
|
||||
}
|
||||
|
||||
export const PricingProductsForm = ({ form }: PricingProductsFormProps) => {
|
||||
const { control, setValue } = form
|
||||
|
||||
const selectedIds = useWatch({
|
||||
control,
|
||||
name: "product_ids",
|
||||
})
|
||||
|
||||
const productRecords = useWatch({
|
||||
control,
|
||||
name: "products",
|
||||
})
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
|
||||
getInitialSelection(selectedIds)
|
||||
)
|
||||
|
||||
const { searchParams, raw } = useProductTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
})
|
||||
const { products, count, isLoading, isError, error } = useProducts(
|
||||
searchParams,
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const updater: OnChangeFn<RowSelectionState> = (fn) => {
|
||||
const state = typeof fn === "function" ? fn(rowSelection) : fn
|
||||
|
||||
const ids = Object.keys(state)
|
||||
const productRecordKeys = Object.keys(productRecords)
|
||||
|
||||
const updatedRecords = productRecordKeys.reduce((acc, key) => {
|
||||
if (ids.includes(key)) {
|
||||
acc[key] = productRecords[key]
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as PricingProductsRecordType)
|
||||
|
||||
const update = ids.map((id) => ({ id }))
|
||||
|
||||
setValue("product_ids", update, { shouldDirty: true, shouldTouch: true })
|
||||
|
||||
/**
|
||||
* Update the product records to ensure that all unselected products
|
||||
* are removed from the form state.
|
||||
*/
|
||||
setValue("products", updatedRecords, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
})
|
||||
|
||||
setRowSelection(state)
|
||||
}
|
||||
|
||||
const columns = useColumns()
|
||||
const filters = useProductTableFilters()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: products || [],
|
||||
columns,
|
||||
count,
|
||||
enablePagination: true,
|
||||
enableRowSelection: (row) => {
|
||||
return row.original.variants.length > 0
|
||||
},
|
||||
getRowId: (row) => row.id,
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
updater,
|
||||
},
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
pageSize={PAGE_SIZE}
|
||||
prefix={PREFIX}
|
||||
count={count}
|
||||
isLoading={isLoading}
|
||||
layout="fill"
|
||||
orderBy={["title", "status", "created_at", "updated_at"]}
|
||||
pagination
|
||||
search
|
||||
queryObject={raw}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<ExtendedProductDTO>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = useProductTableColumns()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: table.getIsAllPageRowsSelected()
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[base]
|
||||
)
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod"
|
||||
import { PricingProductsRecordSchema } from "../../../common/schemas"
|
||||
|
||||
const PricingCustomerGroupsArray = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
)
|
||||
|
||||
export type PricingCustomerGroupsArrayType = z.infer<
|
||||
typeof PricingCustomerGroupsArray
|
||||
>
|
||||
|
||||
export const PricingCreateSchema = z.object({
|
||||
type: z.enum(["sale", "override"]),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
starts_at: z.date().nullable(),
|
||||
ends_at: z.date().nullable(),
|
||||
customer_group_ids: PricingCustomerGroupsArray.optional(),
|
||||
product_ids: z.array(z.object({ id: z.string() })).min(1),
|
||||
products: PricingProductsRecordSchema,
|
||||
})
|
||||
|
||||
export type PricingCreateSchemaType = z.infer<typeof PricingCreateSchema>
|
||||
|
||||
export const PricingDetailsSchema = PricingCreateSchema.pick({
|
||||
type: true,
|
||||
title: true,
|
||||
description: true,
|
||||
starts_at: true,
|
||||
ends_at: true,
|
||||
})
|
||||
|
||||
export const PricingDetailsFields = Object.keys(
|
||||
PricingDetailsSchema.shape
|
||||
) as (keyof typeof PricingDetailsSchema.shape)[]
|
||||
|
||||
export const PricingProductsSchema = PricingCreateSchema.pick({
|
||||
product_ids: true,
|
||||
})
|
||||
|
||||
export const PricingProductsFields = Object.keys(
|
||||
PricingProductsSchema.shape
|
||||
) as (keyof typeof PricingProductsSchema.shape)[]
|
||||
|
||||
export const PricingPricesSchema = PricingCreateSchema.pick({
|
||||
products: true,
|
||||
})
|
||||
|
||||
export const PricingPricesFields = Object.keys(
|
||||
PricingPricesSchema.shape
|
||||
) as (keyof typeof PricingPricesSchema.shape)[]
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingCreate as Component } from "./pricing-create"
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { PricingCreateForm } from "./components/pricing-create-form"
|
||||
|
||||
export const PricingCreate = () => {
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<PricingCreateForm />
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
+13
-13
@@ -1,21 +1,23 @@
|
||||
import { PencilSquare } from "@medusajs/icons"
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
|
||||
import { format } from "date-fns"
|
||||
import { PriceListDTO } from "@medusajs/types"
|
||||
import { Container, Heading, Text } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDate } from "../../../../../hooks/use-date"
|
||||
|
||||
type PricingConfigurationSectionProps = {
|
||||
priceList: PriceList
|
||||
priceList: PriceListDTO
|
||||
}
|
||||
|
||||
export const PricingConfigurationSection = ({
|
||||
priceList,
|
||||
}: PricingConfigurationSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { getFullDate } = useDate()
|
||||
|
||||
const firstCustomerGroups = priceList.customer_groups?.slice(0, 3)
|
||||
const remainingCustomerGroups = priceList.customer_groups?.slice(3)
|
||||
// TODO: Customer groups are not available in the price list schema out-of-the-box atm.
|
||||
// const firstCustomerGroups = priceList.customer_groups?.slice(0, 3) || []
|
||||
// const remainingCustomerGroups = priceList.customer_groups?.slice(3) || []
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
@@ -27,7 +29,7 @@ export const PricingConfigurationSection = ({
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
to: "configurations/edit",
|
||||
to: "configuration",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
@@ -41,7 +43,7 @@ export const PricingConfigurationSection = ({
|
||||
</Text>
|
||||
<Text size="small" className="text-pretty">
|
||||
{priceList.starts_at
|
||||
? format(new Date(priceList.starts_at), "dd MMM yyyy")
|
||||
? getFullDate({ date: priceList.starts_at })
|
||||
: "-"}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -50,12 +52,10 @@ export const PricingConfigurationSection = ({
|
||||
{t("fields.endDate")}
|
||||
</Text>
|
||||
<Text size="small" className="text-pretty">
|
||||
{priceList.ends_at
|
||||
? format(new Date(priceList.ends_at), "dd MMM yyyy")
|
||||
: "-"}
|
||||
{priceList.ends_at ? getFullDate({ date: priceList.ends_at }) : "-"}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
{/* <div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text leading="compact" size="small" weight="plus">
|
||||
{t("pricing.settings.customerGroupsLabel")}
|
||||
</Text>
|
||||
@@ -84,7 +84,7 @@ export const PricingConfigurationSection = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</div> */}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
+6
-6
@@ -1,14 +1,14 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import { PriceListDTO } from "@medusajs/types"
|
||||
import { Container, Heading, StatusBadge, Text, usePrompt } from "@medusajs/ui"
|
||||
import { useAdminDeletePriceList } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDeletePriceList } from "../../../../../hooks/api/price-lists"
|
||||
import { getPriceListStatus } from "../../../common/utils"
|
||||
|
||||
type PricingGeneralSectionProps = {
|
||||
priceList: PriceList
|
||||
priceList: PriceListDTO
|
||||
}
|
||||
|
||||
export const PricingGeneralSection = ({
|
||||
@@ -18,7 +18,7 @@ export const PricingGeneralSection = ({
|
||||
const navigate = useNavigate()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const { mutateAsync } = useAdminDeletePriceList(priceList.id)
|
||||
const { mutateAsync } = useDeletePriceList(priceList.id)
|
||||
|
||||
const overrideCount = priceList.prices?.length || 0
|
||||
|
||||
@@ -28,7 +28,7 @@ export const PricingGeneralSection = ({
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("pricing.deletePriceListWarning", {
|
||||
name: priceList.name,
|
||||
name: priceList.title,
|
||||
}),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
@@ -53,7 +53,7 @@ export const PricingGeneralSection = ({
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{priceList.name}</Heading>
|
||||
<Heading>{priceList.title}</Heading>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<StatusBadge color={color}>{text}</StatusBadge>
|
||||
<ActionMenu
|
||||
+48
-24
@@ -1,23 +1,22 @@
|
||||
import { PencilSquare, Plus } from "@medusajs/icons"
|
||||
import { PriceList, Product } from "@medusajs/medusa"
|
||||
import { PencilSquare, Plus, Trash } from "@medusajs/icons"
|
||||
import { PriceListDTO } from "@medusajs/types"
|
||||
import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
||||
import {
|
||||
useAdminDeletePriceListProductsPrices,
|
||||
useAdminPriceListProducts,
|
||||
} from "medusa-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useProducts } from "../../../../../hooks/api/products"
|
||||
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
|
||||
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
|
||||
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { ExtendedProductDTO } from "../../../../../types/api-responses"
|
||||
|
||||
type PricingProductSectionProps = {
|
||||
priceList: PriceList
|
||||
priceList: PriceListDTO
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
@@ -36,23 +35,21 @@ export const PricingProductSection = ({
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
})
|
||||
const { products, count, isLoading, isError, error } =
|
||||
useAdminPriceListProducts(
|
||||
priceList.id,
|
||||
{
|
||||
...searchParams,
|
||||
expand: "variants,sales_channels,collection",
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
const { products, count, isLoading, isError, error } = useProducts(
|
||||
{
|
||||
...searchParams,
|
||||
price_list_id: [priceList.id],
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
const filters = useProductTableFilters()
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: (products || []) as Product[],
|
||||
data: products || [],
|
||||
count,
|
||||
columns,
|
||||
enablePagination: true,
|
||||
@@ -66,7 +63,6 @@ export const PricingProductSection = ({
|
||||
prefix: PREFIX,
|
||||
})
|
||||
|
||||
const { mutateAsync } = useAdminDeletePriceListProductsPrices(priceList.id)
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
@@ -81,9 +77,9 @@ export const PricingProductSection = ({
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
product_ids: Object.keys(rowSelection),
|
||||
})
|
||||
// The endpoint to batch remove prices by product ids is not implemented in
|
||||
// v2. We either need to implement it or remove the feature.
|
||||
console.log("Not implemented yet.")
|
||||
}
|
||||
|
||||
const handleEdit = async () => {
|
||||
@@ -149,7 +145,31 @@ export const PricingProductSection = ({
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Product>()
|
||||
const ProductRowAction = ({ product }: { product: ExtendedProductDTO }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// TODO: The endpoint to remove prices by product id is not implemented in v2.
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <Trash />,
|
||||
label: t("actions.remove"),
|
||||
onClick: () => {
|
||||
console.log("Not implemented yet.")
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<ExtendedProductDTO>()
|
||||
|
||||
const useColumns = () => {
|
||||
const base = useProductTableColumns()
|
||||
@@ -185,6 +205,10 @@ const useColumns = () => {
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => <ProductRowAction product={row.original} />,
|
||||
}),
|
||||
],
|
||||
[base]
|
||||
)
|
||||
+1
@@ -1 +1,2 @@
|
||||
export { pricingLoader as loader } from "./loader"
|
||||
export { PricingDetail as Component } from "./pricing-detail"
|
||||
@@ -0,0 +1,20 @@
|
||||
import { LoaderFunctionArgs } from "react-router-dom"
|
||||
import { priceListsQueryKeys } from "../../../hooks/api/price-lists"
|
||||
import { client } from "../../../lib/client"
|
||||
import { queryClient } from "../../../lib/medusa"
|
||||
import { PriceListRes } from "../../../types/api-responses"
|
||||
|
||||
const pricingDetailQuery = (id: string) => ({
|
||||
queryKey: priceListsQueryKeys.detail(id),
|
||||
queryFn: async () => client.priceLists.retrieve(id),
|
||||
})
|
||||
|
||||
export const pricingLoader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const id = params.id
|
||||
const query = pricingDetailQuery(id!)
|
||||
|
||||
return (
|
||||
queryClient.getQueryData<PriceListRes>(query.queryKey) ??
|
||||
(await queryClient.fetchQuery(query))
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { useAdminPriceList } from "medusa-react"
|
||||
import { Outlet, useParams } from "react-router-dom"
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { usePriceList } from "../../../hooks/api/price-lists"
|
||||
import { PricingConfigurationSection } from "./components/pricing-configuration-section"
|
||||
import { PricingGeneralSection } from "./components/pricing-general-section"
|
||||
import { PricingProductSection } from "./components/pricing-product-section"
|
||||
@@ -8,7 +8,7 @@ import { PricingProductSection } from "./components/pricing-product-section"
|
||||
export const PricingDetail = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { price_list, isLoading, isError, error } = useAdminPriceList(id!)
|
||||
const { price_list, isLoading, isError, error } = usePriceList(id!)
|
||||
|
||||
if (isLoading || !price_list) {
|
||||
return <div>Loading...</div>
|
||||
+9
-37
@@ -1,7 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import { Button, Input, RadioGroup, Switch, Textarea } from "@medusajs/ui"
|
||||
import { useAdminUpdatePriceList } from "medusa-react"
|
||||
import { PriceListDTO } from "@medusajs/types"
|
||||
import { Button, Input, RadioGroup, Textarea } from "@medusajs/ui"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
@@ -10,17 +9,17 @@ import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useUpdatePriceList } from "../../../../../hooks/api/price-lists"
|
||||
import { PriceListType } from "../../../common/constants"
|
||||
|
||||
type EditPriceListFormProps = {
|
||||
priceList: PriceList
|
||||
priceList: PriceListDTO
|
||||
}
|
||||
|
||||
const EditPriceListFormSchema = z.object({
|
||||
type: z.nativeEnum(PriceListType),
|
||||
name: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
includes_tax: z.boolean(),
|
||||
})
|
||||
|
||||
export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
|
||||
@@ -30,14 +29,13 @@ export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
|
||||
const form = useForm<z.infer<typeof EditPriceListFormSchema>>({
|
||||
defaultValues: {
|
||||
type: priceList.type,
|
||||
name: priceList.name,
|
||||
title: priceList.title,
|
||||
description: priceList.description,
|
||||
includes_tax: priceList.includes_tax,
|
||||
},
|
||||
resolver: zodResolver(EditPriceListFormSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync } = useAdminUpdatePriceList(priceList.id)
|
||||
const { mutateAsync } = useUpdatePriceList(priceList.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await mutateAsync(values, {
|
||||
@@ -85,11 +83,11 @@ export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="name"
|
||||
name="title"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.name")}</Form.Label>
|
||||
<Form.Label>{t("fields.title")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
@@ -114,32 +112,6 @@ export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="includes_tax"
|
||||
render={({ field: { value, onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div>
|
||||
<div className="flex items-start justify-between">
|
||||
<Form.Label>{t("fields.taxInclusivePricing")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Switch
|
||||
{...field}
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
</Form.Control>
|
||||
</div>
|
||||
<Form.Hint>
|
||||
{t("pricing.settings.taxInclusivePricingHint")}
|
||||
</Form.Hint>
|
||||
<Form.ErrorMessage />
|
||||
</div>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</RouteDrawer.Body>
|
||||
<RouteDrawer.Footer className="shrink-0">
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
+2
-2
@@ -1,15 +1,15 @@
|
||||
import { Heading } from "@medusajs/ui"
|
||||
import { useAdminPriceList } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { RouteDrawer } from "../../../components/route-modal"
|
||||
import { usePriceList } from "../../../hooks/api/price-lists"
|
||||
import { EditPriceListForm } from "./components/edit-price-list-form"
|
||||
|
||||
export const PricingEdit = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
|
||||
const { price_list, isLoading, isError, error } = useAdminPriceList(id!)
|
||||
const { price_list, isLoading, isError, error } = usePriceList(id!)
|
||||
|
||||
const ready = !isLoading && price_list
|
||||
|
||||
+8
-6
@@ -1,8 +1,9 @@
|
||||
import { Button, Container, Heading } from "@medusajs/ui"
|
||||
import { useAdminPriceLists } from "medusa-react"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { usePriceLists } from "../../../../../hooks/api/price-lists"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { usePricingTableColumns } from "./use-pricing-table-columns"
|
||||
import { usePricingTableQuery } from "./use-pricing-table-query"
|
||||
@@ -15,12 +16,13 @@ export const PricingListTable = () => {
|
||||
const { searchParams, raw } = usePricingTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
const { price_lists, count, isLoading, isError, error } = useAdminPriceLists(
|
||||
const { price_lists, count, isLoading, isError, error } = usePriceLists(
|
||||
// {
|
||||
// ...searchParams, // The query params are not implemented, and any search params other than expand and fields will throw an error
|
||||
// },
|
||||
undefined,
|
||||
{
|
||||
...searchParams,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import { PriceListDTO } from "@medusajs/types"
|
||||
import { usePrompt } from "@medusajs/ui"
|
||||
import { useAdminDeletePriceList } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { useDeletePriceList } from "../../../../../hooks/api/price-lists"
|
||||
|
||||
type PricingTableActionsProps = {
|
||||
priceList: PriceList
|
||||
priceList: PriceListDTO
|
||||
}
|
||||
|
||||
export const PricingTableActions = ({
|
||||
@@ -15,13 +15,13 @@ export const PricingTableActions = ({
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const { mutateAsync } = useAdminDeletePriceList(priceList.id)
|
||||
const { mutateAsync } = useDeletePriceList(priceList.id)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("pricing.deletePriceListWarning", {
|
||||
name: priceList.name,
|
||||
name: priceList.title,
|
||||
}),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
+5
-20
@@ -1,4 +1,4 @@
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import { PriceListDTO } from "@medusajs/types"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -6,32 +6,17 @@ import { StatusCell } from "../../../../../components/table/table-cells/common/s
|
||||
import { getPriceListStatus } from "../../../common/utils"
|
||||
import { PricingTableActions } from "./pricing-table-actions"
|
||||
|
||||
const columnHelper = createColumnHelper<PriceList>()
|
||||
const columnHelper = createColumnHelper<PriceListDTO>()
|
||||
|
||||
export const usePricingTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
columnHelper.accessor("title", {
|
||||
header: t("fields.name"),
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("type", {
|
||||
header: t("fields.type"),
|
||||
cell: ({ getValue }) => {
|
||||
const label =
|
||||
getValue() === "sale"
|
||||
? t("pricing.type.sale")
|
||||
: t("pricing.type.override")
|
||||
|
||||
return (
|
||||
<div className="flex size-full items-center overflow-hidden">
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("status", {
|
||||
header: t("fields.status"),
|
||||
cell: ({ row }) => {
|
||||
@@ -40,8 +25,8 @@ export const usePricingTableColumns = () => {
|
||||
return <StatusCell color={color}>{text}</StatusCell>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("customer_groups", {
|
||||
header: t("customerGroups.domain"),
|
||||
columnHelper.accessor("prices", {
|
||||
header: t("fields.prices"),
|
||||
cell: (info) => info.getValue()?.length || "-",
|
||||
}),
|
||||
columnHelper.display({
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./pricing-products-prices-form"
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
CreatePriceListPriceDTO,
|
||||
PriceListDTO,
|
||||
UpdatePriceListPriceDTO,
|
||||
} from "@medusajs/types"
|
||||
import { Button } from "@medusajs/ui"
|
||||
import { UseFormReturn, useForm, useWatch } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { DataGrid } from "../../../../../components/grid/data-grid"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { useCurrencies } from "../../../../../hooks/api/currencies"
|
||||
import {
|
||||
usePriceListAddPrices,
|
||||
usePriceListRemovePrices,
|
||||
useUpdatePriceList,
|
||||
} from "../../../../../hooks/api/price-lists"
|
||||
import { useStore } from "../../../../../hooks/api/store"
|
||||
import { castNumber } from "../../../../../lib/cast-number"
|
||||
import {
|
||||
getDbAmount,
|
||||
getPresentationalAmount,
|
||||
} from "../../../../../lib/money-amount-helpers"
|
||||
import { ExtendedProductDTO } from "../../../../../types/api-responses"
|
||||
import { usePriceListGridColumns } from "../../../common/hooks/use-price-list-grid-columns"
|
||||
import {
|
||||
PricingProductsRecordSchema,
|
||||
PricingVariantsRecordType,
|
||||
} from "../../../common/schemas"
|
||||
import { isProductRow } from "../../../common/utils"
|
||||
|
||||
type PricingProductPricesFormProps = {
|
||||
priceList: PriceListDTO
|
||||
products: ExtendedProductDTO[]
|
||||
}
|
||||
|
||||
const PricingProductPricesSchema = z.object({
|
||||
products: PricingProductsRecordSchema,
|
||||
})
|
||||
|
||||
type VariantsPriceRecord = Record<
|
||||
string,
|
||||
{ currency_code: string; amount: number; id: string }[]
|
||||
>
|
||||
|
||||
const initRecord = (priceList: PriceListDTO): VariantsPriceRecord => {
|
||||
const prices = priceList.prices
|
||||
const sortedPrices: VariantsPriceRecord = {}
|
||||
|
||||
if (!prices) {
|
||||
return sortedPrices
|
||||
}
|
||||
|
||||
prices.forEach((price) => {
|
||||
// @ts-ignore - Type is wrong
|
||||
const { variant_id, currency_code, amount, id } = price
|
||||
|
||||
if (!currency_code || !amount || !variant_id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!sortedPrices[variant_id]) {
|
||||
sortedPrices[variant_id] = []
|
||||
}
|
||||
|
||||
sortedPrices[variant_id] = [
|
||||
...sortedPrices[variant_id],
|
||||
{ currency_code, amount, id },
|
||||
]
|
||||
})
|
||||
|
||||
return sortedPrices
|
||||
}
|
||||
|
||||
export const PricingProductPricesForm = ({
|
||||
priceList,
|
||||
products,
|
||||
}: PricingProductPricesFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const record = useMemo(() => initRecord(priceList), [priceList])
|
||||
|
||||
const {
|
||||
store,
|
||||
isLoading: isStoreLoading,
|
||||
isError: isStoreError,
|
||||
error: storeError,
|
||||
} = useStore()
|
||||
|
||||
const {
|
||||
currencies,
|
||||
isLoading: isCurrenciesLoading,
|
||||
isError: isCurrencyError,
|
||||
error: currencyError,
|
||||
} = useCurrencies(
|
||||
{
|
||||
code: store?.supported_currency_codes,
|
||||
},
|
||||
{
|
||||
enabled: !!store,
|
||||
}
|
||||
)
|
||||
const form = useForm<z.infer<typeof PricingProductPricesSchema>>({
|
||||
defaultValues: {
|
||||
products: {},
|
||||
},
|
||||
resolver: zodResolver(PricingProductPricesSchema),
|
||||
})
|
||||
|
||||
const existingProducts = useWatch({
|
||||
control: form.control,
|
||||
name: "products",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
initDefaultValues(products, existingProducts, form, record)
|
||||
}, [existingProducts, form, products, record])
|
||||
|
||||
const { mutateAsync: updateAsync, isPending: isUpdatePending } =
|
||||
useUpdatePriceList(priceList.id)
|
||||
|
||||
const { mutateAsync: deleteAsync, isPending: isDeletePending } =
|
||||
usePriceListRemovePrices(priceList.id)
|
||||
|
||||
const { mutateAsync: addAsync, isPending: isAddPending } =
|
||||
usePriceListAddPrices(priceList.id)
|
||||
|
||||
const isPending = isUpdatePending || isDeletePending || isAddPending
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
const { products } = values
|
||||
|
||||
const { pricesToDelete, pricesToCreate, pricesToUpdate } = sortPrices(
|
||||
products,
|
||||
record
|
||||
)
|
||||
|
||||
let failed = false
|
||||
|
||||
// TODO: Currently not working, need to fix the API
|
||||
// await updateAsync(
|
||||
// {
|
||||
// // @ts-expect-error - type is wrong
|
||||
// prices: pricesToUpdate,
|
||||
// },
|
||||
// {
|
||||
// onError(error) {
|
||||
// console.error(error)
|
||||
// failed = true
|
||||
// },
|
||||
// }
|
||||
// )
|
||||
|
||||
// if (failed) {
|
||||
// return
|
||||
// }
|
||||
|
||||
if (pricesToDelete.length) {
|
||||
await deleteAsync(
|
||||
{
|
||||
ids: pricesToDelete,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
console.error(error)
|
||||
failed = true
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pricesToCreate.length) {
|
||||
await addAsync(
|
||||
{
|
||||
// @ts-expect-error - type is wrong
|
||||
prices: pricesToCreate,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
console.error(error)
|
||||
failed = true
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return
|
||||
}
|
||||
|
||||
handleSuccess()
|
||||
})
|
||||
|
||||
const columns = usePriceListGridColumns({ currencies })
|
||||
|
||||
const initializing =
|
||||
isStoreLoading || isCurrenciesLoading || !products || !store || !currencies
|
||||
|
||||
if (isStoreError) {
|
||||
throw storeError
|
||||
}
|
||||
|
||||
if (isCurrencyError) {
|
||||
throw currencyError
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form onSubmit={handleSubmit} className="flex size-full flex-col">
|
||||
<RouteFocusModal.Header>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<RouteFocusModal.Close asChild>
|
||||
<Button size="small" variant="secondary">
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button size="small" type="submit" isLoading={isPending}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</RouteFocusModal.Header>
|
||||
<RouteFocusModal.Body className="flex flex-col overflow-hidden">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={products}
|
||||
getSubRows={(row) => {
|
||||
if (isProductRow(row)) {
|
||||
return row.variants
|
||||
}
|
||||
}}
|
||||
isLoading={initializing}
|
||||
state={form}
|
||||
/>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
function initDefaultValues(
|
||||
products: ExtendedProductDTO[],
|
||||
existingProducts: any,
|
||||
form: UseFormReturn<z.infer<typeof PricingProductPricesSchema>>,
|
||||
record: VariantsPriceRecord
|
||||
) {
|
||||
products.forEach((product) => {
|
||||
if (existingProducts[product.id] || !product.variants) {
|
||||
return
|
||||
}
|
||||
|
||||
form.setValue(
|
||||
`products.${product.id}.variants`,
|
||||
{
|
||||
...product.variants.reduce((variants, variant) => {
|
||||
const currencyPrices = record[variant.id] || []
|
||||
|
||||
variants[variant.id] = {
|
||||
currency_prices: currencyPrices.reduce(
|
||||
(prices, { currency_code, amount, id }) => {
|
||||
const presentationAmount = getPresentationalAmount(
|
||||
amount,
|
||||
currency_code
|
||||
).toString()
|
||||
|
||||
prices[currency_code] = {
|
||||
amount: presentationAmount,
|
||||
id,
|
||||
}
|
||||
return prices
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
{
|
||||
amount: string
|
||||
id: string
|
||||
}
|
||||
>
|
||||
),
|
||||
region_prices: {},
|
||||
}
|
||||
|
||||
return variants
|
||||
}, {} as PricingVariantsRecordType),
|
||||
},
|
||||
{
|
||||
shouldDirty: false,
|
||||
shouldTouch: false,
|
||||
shouldValidate: false,
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function sortPrices(
|
||||
products: Record<
|
||||
string,
|
||||
{
|
||||
variants: Record<
|
||||
string,
|
||||
{
|
||||
currency_prices: Record<
|
||||
string,
|
||||
| { amount?: string | undefined; id?: string | null | undefined }
|
||||
| undefined
|
||||
>
|
||||
region_prices: Record<
|
||||
string,
|
||||
| { amount?: string | undefined; id?: string | null | undefined }
|
||||
| undefined
|
||||
>
|
||||
}
|
||||
>
|
||||
}
|
||||
>,
|
||||
record: VariantsPriceRecord
|
||||
) {
|
||||
const pricesToUpdate: UpdatePriceListPriceDTO[] = []
|
||||
const pricesToCreate: CreatePriceListPriceDTO[] = []
|
||||
const pricesToDelete: string[] = []
|
||||
|
||||
for (const [_productId, product] of Object.entries(products)) {
|
||||
const { variants } = product
|
||||
|
||||
for (const [variantId, variant] of Object.entries(variants)) {
|
||||
const { currency_prices } = variant
|
||||
|
||||
for (const [currencyCode, currencyPrice] of Object.entries(
|
||||
currency_prices
|
||||
)) {
|
||||
if (!currencyPrice) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (currencyPrice.id && currencyPrice.amount) {
|
||||
const originalPrice = record[variantId].find(
|
||||
(p) => p.id === currencyPrice.id
|
||||
)
|
||||
|
||||
// If the price has not changed, we don't need to update it
|
||||
if (
|
||||
originalPrice &&
|
||||
originalPrice.amount ===
|
||||
getDbAmount(castNumber(currencyPrice.amount), currencyCode)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
pricesToUpdate.push({
|
||||
id: currencyPrice.id,
|
||||
amount: getDbAmount(castNumber(currencyPrice.amount), currencyCode),
|
||||
currency_code: currencyCode,
|
||||
// @ts-expect-error type is wrong
|
||||
variant_id: variantId,
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (currencyPrice.id && !currencyPrice.amount) {
|
||||
pricesToDelete.push(currencyPrice.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currencyPrice.id && currencyPrice.amount) {
|
||||
pricesToCreate.push({
|
||||
amount: getDbAmount(castNumber(currencyPrice.amount), currencyCode),
|
||||
currency_code: currencyCode,
|
||||
// @ts-expect-error type is wrong
|
||||
variant_id: variantId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { pricesToDelete, pricesToCreate, pricesToUpdate }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingProductsPrices as Component } from "./pricing-products-prices"
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { useParams, useSearchParams } from "react-router-dom"
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { usePriceList } from "../../../hooks/api/price-lists"
|
||||
import { useProducts } from "../../../hooks/api/products"
|
||||
import { PricingProductPricesForm } from "./components/pricing-products-prices-form"
|
||||
|
||||
export const PricingProductsPrices = () => {
|
||||
const { id } = useParams()
|
||||
const [searchParams] = useSearchParams()
|
||||
const ids = searchParams.get("ids[]")
|
||||
|
||||
const { price_list, isLoading, isError, error } = usePriceList(id!)
|
||||
const productIds = ids?.split(",")
|
||||
|
||||
const {
|
||||
products,
|
||||
isLoading: isProductsLoading,
|
||||
isError: isProductsError,
|
||||
error: productError,
|
||||
} = useProducts({
|
||||
id: productIds,
|
||||
limit: productIds?.length || 9999, // Temporary until we support lazy loading in the DataGrid
|
||||
fields: "title,thumbnail,*variants",
|
||||
})
|
||||
|
||||
const ready = !isLoading && !!price_list && !isProductsLoading && !!products
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (isProductsError) {
|
||||
throw productError
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
{ready && (
|
||||
<PricingProductPricesForm priceList={price_list} products={products} />
|
||||
)}
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
+11
-12
@@ -5,23 +5,24 @@ import {
|
||||
RowSelectionState,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table"
|
||||
import { adminCurrenciesKeys, useAdminCustomQuery } from "medusa-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { CurrencyDTO, StoreDTO } from "@medusajs/types"
|
||||
import { StoreDTO } from "@medusajs/types"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { useForm } from "react-hook-form"
|
||||
import {
|
||||
RouteFocusModal,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useCurrencies } from "../../../../../hooks/api/currencies"
|
||||
import { useUpdateStore } from "../../../../../hooks/api/store"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { useCurrenciesTableColumns } from "../../../common/hooks/use-currencies-table-columns"
|
||||
import { useCurrenciesTableQuery } from "../../../common/hooks/use-currencies-table-query"
|
||||
import { useUpdateStore } from "../../../../../hooks/api/store"
|
||||
|
||||
type AddCurrenciesFormProps = {
|
||||
store: StoreDTO
|
||||
@@ -67,12 +68,10 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
|
||||
prefix: PREFIX,
|
||||
})
|
||||
|
||||
const { data, isLoading, isError, error } = useAdminCustomQuery(
|
||||
"/admin/currencies",
|
||||
adminCurrenciesKeys.list(raw),
|
||||
const { currencies, count, isLoading, isError, error } = useCurrencies(
|
||||
searchParams,
|
||||
{
|
||||
keepPreviousData: true,
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -81,9 +80,9 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: (data?.currencies ?? []) as CurrencyDTO[],
|
||||
data: currencies ?? [],
|
||||
columns,
|
||||
count: data?.count,
|
||||
count: count,
|
||||
getRowId: (row) => row.code,
|
||||
enableRowSelection: (row) => !preSelectedRows.includes(row.original.code),
|
||||
enablePagination: true,
|
||||
@@ -95,7 +94,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading: isMutating } = useUpdateStore(store.id)
|
||||
const { mutateAsync, isPending } = useUpdateStore(store.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (data) => {
|
||||
const currencies = Array.from(
|
||||
@@ -139,7 +138,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
</RouteFocusModal.Close>
|
||||
<Button size="small" type="submit" isLoading={isMutating}>
|
||||
<Button size="small" type="submit" isLoading={isPending}>
|
||||
{t("actions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -149,7 +148,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => {
|
||||
<DataTable
|
||||
table={table}
|
||||
pageSize={PAGE_SIZE}
|
||||
count={data?.count}
|
||||
count={count}
|
||||
columns={columns}
|
||||
layout="fill"
|
||||
pagination
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { AddCurrenciesForm } from "./components/add-currencies-form/add-currencies-form"
|
||||
import { useStore } from "../../../hooks/api/store"
|
||||
import { AddCurrenciesForm } from "./components/add-currencies-form/add-currencies-form"
|
||||
|
||||
export const StoreAddCurrencies = () => {
|
||||
const { store, isLoading, isError, error } = useStore({})
|
||||
const { store, isLoading, isError, error } = useStore()
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
|
||||
+1
-5
@@ -9,7 +9,6 @@ import {
|
||||
} from "@medusajs/ui"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
|
||||
import { adminStoreKeys, useAdminCustomPost } from "medusa-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../../components/common/action-menu"
|
||||
@@ -157,10 +156,7 @@ const CurrencyActions = ({
|
||||
currency: CurrencyDTO
|
||||
currencyCodes: string[]
|
||||
}) => {
|
||||
const { mutateAsync } = useAdminCustomPost(
|
||||
`/admin/stores/${storeId}`,
|
||||
adminStoreKeys.details()
|
||||
)
|
||||
const { mutateAsync } = useUpdateStore(storeId)
|
||||
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
|
||||
Reference in New Issue
Block a user