feat(medusa,dahsboard): Initial work on pricing domain (#6633)
**What** - Initial work on pricing domain. - List page - Details page - Partial edit form - Fixes admin/price-lists/:id/products endpoint to allow filtering products by multiple ids. **Note** Pushing this now as the current design need a bit of tweaking so we display all relevant data to users. Future PR will include completed Edit moda, Create modal, Edit prices modal and Add prices modal.
This commit is contained in:
+2
-3
@@ -487,14 +487,13 @@ export const DataGridRoot = <
|
||||
}, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory])
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-b p-4"></div>
|
||||
<div className="size-full overflow-hidden">
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
style={{
|
||||
overflow: "auto",
|
||||
position: "relative",
|
||||
height: "600px",
|
||||
height: "100%",
|
||||
userSelect: isSelecting || isDragging ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -11,13 +11,9 @@ export const DataGrid = <TData, TFieldValues extends FieldValues = any>({
|
||||
isLoading,
|
||||
...props
|
||||
}: DataGridProps<TData, TFieldValues>) => {
|
||||
return (
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<DataGridSkeleton columns={props.columns} rowCount={10} />
|
||||
) : (
|
||||
<DataGridRoot {...props} />
|
||||
)}
|
||||
</div>
|
||||
return isLoading ? (
|
||||
<DataGridSkeleton columns={props.columns} rowCount={10} />
|
||||
) : (
|
||||
<DataGridRoot {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export const useProductTableQuery = ({
|
||||
"tags",
|
||||
"type_id",
|
||||
"status",
|
||||
"id",
|
||||
],
|
||||
prefix
|
||||
)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { usePrompt } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
export const useFormPrompt = () => {
|
||||
const { t } = useTranslation()
|
||||
const fn = usePrompt()
|
||||
|
||||
const promptValues = {
|
||||
title: t("general.unsavedChangesTitle"),
|
||||
description: t("general.unsavedChangesDescription"),
|
||||
cancelText: t("actions.cancel"),
|
||||
confirmText: t("actions.continue"),
|
||||
}
|
||||
|
||||
const prompt = async () => {
|
||||
return await fn(promptValues)
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
@@ -405,12 +405,34 @@ const router = createBrowserRouter([
|
||||
},
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: () => import("../../routes/pricing/list"),
|
||||
path: "",
|
||||
lazy: () => import("../../routes/pricing/pricing-list"),
|
||||
children: [
|
||||
// {
|
||||
// path: "create",
|
||||
// lazy: () => import("../../routes/pricing/pricing-create"),
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../routes/pricing/details"),
|
||||
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"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Re-implementation of enum from `@medusajs/medusa` as it cannot be imported
|
||||
*/
|
||||
export enum PriceListStatus {
|
||||
ACTIVE = "active",
|
||||
DRAFT = "draft",
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-implementation of enum from `@medusajs/medusa` as it cannot be imported
|
||||
*/
|
||||
export enum PriceListType {
|
||||
SALE = "sale",
|
||||
OVERRIDE = "override",
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import { TFunction } from "i18next"
|
||||
import { PriceListStatus } from "./constants"
|
||||
|
||||
const getValues = (priceList: PriceList) => {
|
||||
const startsAt = priceList.starts_at
|
||||
const endsAt = priceList.ends_at
|
||||
|
||||
const isExpired = endsAt ? new Date(endsAt) < new Date() : false
|
||||
const isScheduled = startsAt ? new Date(startsAt) > new Date() : false
|
||||
const isDraft = priceList.status === PriceListStatus.DRAFT
|
||||
|
||||
return {
|
||||
isExpired,
|
||||
isScheduled,
|
||||
isDraft,
|
||||
}
|
||||
}
|
||||
|
||||
export const getPriceListStatus = (
|
||||
t: TFunction<"translation">,
|
||||
priceList: PriceList
|
||||
) => {
|
||||
const { isExpired, isScheduled, isDraft } = getValues(priceList)
|
||||
|
||||
let text = t("pricing.status.active")
|
||||
let color: "red" | "grey" | "orange" | "green" = "green"
|
||||
|
||||
if (isScheduled) {
|
||||
color = "orange"
|
||||
text = t("pricing.status.scheduled")
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
color = "grey"
|
||||
text = t("pricing.status.draft")
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
color = "red"
|
||||
text = t("pricing.status.expired")
|
||||
}
|
||||
|
||||
return {
|
||||
color,
|
||||
text,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Container, Heading } from "@medusajs/ui";
|
||||
|
||||
export const PricingDetails = () => {
|
||||
return (
|
||||
<div>
|
||||
<Container>
|
||||
<Heading>Pricing</Heading>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { PricingDetails as Component } from "./details";
|
||||
@@ -1 +0,0 @@
|
||||
export { PricingList as Component } from "./list";
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Container, Heading } from "@medusajs/ui";
|
||||
|
||||
export const PricingList = () => {
|
||||
return (
|
||||
<div>
|
||||
<Container>
|
||||
<Heading>Pricing</Heading>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./pricing-general-section"
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import {
|
||||
Container,
|
||||
Heading,
|
||||
StatusBadge,
|
||||
Text,
|
||||
Tooltip,
|
||||
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 { getPriceListStatus } from "../../../common/utils"
|
||||
|
||||
type PricingGeneralSectionProps = {
|
||||
priceList: PriceList
|
||||
}
|
||||
|
||||
export const PricingGeneralSection = ({
|
||||
priceList,
|
||||
}: PricingGeneralSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const { mutateAsync } = useAdminDeletePriceList(priceList.id)
|
||||
|
||||
const overrideCount = priceList.prices?.length || 0
|
||||
const firstCustomerGroups = priceList.customer_groups?.slice(0, 3)
|
||||
const remainingCustomerGroups = priceList.customer_groups?.slice(3)
|
||||
|
||||
const { color, text } = getPriceListStatus(t, priceList)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("pricing.deletePriceListWarning", {
|
||||
name: priceList.name,
|
||||
}),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync(undefined, {
|
||||
onSuccess: () => {
|
||||
navigate("..", { replace: true })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const type =
|
||||
priceList.type === "sale"
|
||||
? t("pricing.type.sale")
|
||||
: t("pricing.type.override")
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{priceList.name}</Heading>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<StatusBadge color={color}>{text}</StatusBadge>
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
to: "edit",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.delete"),
|
||||
onClick: handleDelete,
|
||||
icon: <Trash />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text leading="compact" size="small" weight="plus">
|
||||
{t("fields.type")}
|
||||
</Text>
|
||||
<Text size="small" className="text-pretty">
|
||||
{type}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text leading="compact" size="small" weight="plus">
|
||||
{t("fields.description")}
|
||||
</Text>
|
||||
<Text size="small" className="text-pretty">
|
||||
{priceList.description}
|
||||
</Text>
|
||||
</div>
|
||||
<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>
|
||||
<Text size="small" className="text-pretty">
|
||||
<span>
|
||||
{firstCustomerGroups.length > 0
|
||||
? firstCustomerGroups.join(", ")
|
||||
: "-"}
|
||||
</span>
|
||||
{remainingCustomerGroups.length > 0 && (
|
||||
<Tooltip
|
||||
content={
|
||||
<ul>
|
||||
{remainingCustomerGroups.map((cg) => (
|
||||
<li key={cg.id}>{cg.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
>
|
||||
<span className="text-ui-fg-muted">
|
||||
{" "}
|
||||
{t("general.plusCountMore", {
|
||||
count: remainingCustomerGroups.length,
|
||||
})}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<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.priceOverridesLabel")}
|
||||
</Text>
|
||||
<Text size="small" className="text-pretty">
|
||||
{overrideCount || "-"}
|
||||
</Text>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./pricing-product-section"
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { PencilSquare, Plus } from "@medusajs/icons"
|
||||
import { PriceList, Product } from "@medusajs/medusa"
|
||||
import { Checkbox, Container, Heading, usePrompt } from "@medusajs/ui"
|
||||
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 { 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"
|
||||
|
||||
type PricingProductSectionProps = {
|
||||
priceList: PriceList
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const PREFIX = "p"
|
||||
|
||||
export const PricingProductSection = ({
|
||||
priceList,
|
||||
}: PricingProductSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
const { searchParams, raw } = useProductTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
prefix: PREFIX,
|
||||
})
|
||||
const { products, count, isLoading, isError, error } =
|
||||
useAdminPriceListProducts(
|
||||
priceList.id,
|
||||
{
|
||||
...searchParams,
|
||||
expand: "variants,sales_channels,collection",
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const filters = useProductTableFilters()
|
||||
const columns = useColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: (products || []) as Product[],
|
||||
count,
|
||||
columns,
|
||||
enablePagination: true,
|
||||
enableRowSelection: true,
|
||||
pageSize: PAGE_SIZE,
|
||||
getRowId: (row) => row.id,
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
updater: setRowSelection,
|
||||
},
|
||||
prefix: PREFIX,
|
||||
})
|
||||
|
||||
const { mutateAsync } = useAdminDeletePriceListProductsPrices(priceList.id)
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("pricing.products.deleteProductsPricesWarning", {
|
||||
count: Object.keys(rowSelection).length,
|
||||
}),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync({
|
||||
product_ids: Object.keys(rowSelection),
|
||||
})
|
||||
}
|
||||
|
||||
const handleEdit = async () => {
|
||||
const ids = Object.keys(rowSelection).join(",")
|
||||
|
||||
navigate(`/pricing/${priceList.id}/products/edit?ids[]=${ids}`)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{t("products.domain")}</Heading>
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("pricing.prices.addPrices"),
|
||||
to: "products/add",
|
||||
icon: <Plus />,
|
||||
},
|
||||
{
|
||||
label: t("pricing.prices.editPrices"),
|
||||
to: "products/edit",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
filters={filters}
|
||||
columns={columns}
|
||||
count={count}
|
||||
pageSize={PAGE_SIZE}
|
||||
isLoading={isLoading}
|
||||
navigateTo={(row) => `/products/${row.original.id}`}
|
||||
orderBy={["title", "created_at", "updated_at"]}
|
||||
commands={[
|
||||
{
|
||||
action: handleEdit,
|
||||
label: t("actions.edit"),
|
||||
shortcut: "e",
|
||||
},
|
||||
{
|
||||
action: handleDelete,
|
||||
label: t("actions.delete"),
|
||||
shortcut: "d",
|
||||
},
|
||||
]}
|
||||
pagination
|
||||
search
|
||||
prefix={PREFIX}
|
||||
queryObject={raw}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Product>()
|
||||
|
||||
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()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
...base,
|
||||
],
|
||||
[base]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingDetail as Component } from "./pricing-detail"
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useAdminPriceList } from "medusa-react"
|
||||
import { Outlet, useParams } from "react-router-dom"
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { PricingGeneralSection } from "./components/pricing-general-section"
|
||||
import { PricingProductSection } from "./components/pricing-product-section"
|
||||
|
||||
export const PricingDetail = () => {
|
||||
const { id } = useParams()
|
||||
|
||||
const { price_list, isLoading, isError, error } = useAdminPriceList(id!)
|
||||
|
||||
if (isLoading || !price_list) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<PricingGeneralSection priceList={price_list} />
|
||||
<PricingProductSection priceList={price_list} />
|
||||
<JsonViewSection data={price_list} />
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
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 { useForm } from "react-hook-form"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { z } from "zod"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import {
|
||||
RouteDrawer,
|
||||
useRouteModal,
|
||||
} from "../../../../../components/route-modal"
|
||||
import { PriceListType } from "../../../common/constants"
|
||||
|
||||
type EditPriceListFormProps = {
|
||||
priceList: PriceList
|
||||
}
|
||||
|
||||
const EditPriceListFormSchema = z.object({
|
||||
type: z.nativeEnum(PriceListType),
|
||||
name: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
includes_tax: z.boolean(),
|
||||
})
|
||||
|
||||
export const EditPriceListForm = ({ priceList }: EditPriceListFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleSuccess } = useRouteModal()
|
||||
|
||||
const form = useForm<z.infer<typeof EditPriceListFormSchema>>({
|
||||
defaultValues: {
|
||||
type: priceList.type,
|
||||
name: priceList.name,
|
||||
description: priceList.description,
|
||||
includes_tax: priceList.includes_tax,
|
||||
},
|
||||
resolver: zodResolver(EditPriceListFormSchema),
|
||||
})
|
||||
|
||||
const { mutateAsync } = useAdminUpdatePriceList(priceList.id)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await mutateAsync(values, {
|
||||
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="type"
|
||||
render={({ field: { onChange, ...field } }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<div>
|
||||
<Form.Label>{t("fields.type")}</Form.Label>
|
||||
<Form.Hint>{t("pricing.settings.typeHint")}</Form.Hint>
|
||||
</div>
|
||||
<Form.Control>
|
||||
<RadioGroup {...field} onValueChange={onChange}>
|
||||
<RadioGroup.ChoiceBox
|
||||
value={PriceListType.SALE}
|
||||
label={t("pricing.type.sale")}
|
||||
description={t("pricing.settings.saleTypeHint")}
|
||||
/>
|
||||
<RadioGroup.ChoiceBox
|
||||
value={PriceListType.OVERRIDE}
|
||||
label={t("pricing.type.override")}
|
||||
description={t("pricing.settings.overrideTypeHint")}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</Form.Control>
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.name")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./edit-price-list-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingEdit as Component } from "./pricing-edit"
|
||||
@@ -0,0 +1,28 @@
|
||||
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 { 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 ready = !isLoading && price_list
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteDrawer>
|
||||
<RouteDrawer.Header>
|
||||
<Heading>{t("pricing.settings.editPriceListTitle")}</Heading>
|
||||
</RouteDrawer.Header>
|
||||
{ready && <EditPriceListForm priceList={price_list} />}
|
||||
</RouteDrawer>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./pricing-list-table"
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { Button, Container, Heading } from "@medusajs/ui"
|
||||
import { useAdminPriceLists } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
import { DataTable } from "../../../../../components/table/data-table"
|
||||
import { useDataTable } from "../../../../../hooks/use-data-table"
|
||||
import { usePricingTableColumns } from "./use-pricing-table-columns"
|
||||
import { usePricingTableQuery } from "./use-pricing-table-query"
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export const PricingListTable = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { searchParams, raw } = usePricingTableQuery({
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
const { price_lists, count, isLoading, isError, error } = useAdminPriceLists(
|
||||
{
|
||||
...searchParams,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const columns = usePricingTableColumns()
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: price_lists || [],
|
||||
columns,
|
||||
count,
|
||||
enablePagination: true,
|
||||
getRowId: (row) => row.id,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{t("pricing.domain")}</Heading>
|
||||
<Button size="small" variant="secondary" asChild>
|
||||
<Link to="create">{t("actions.create")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
count={count}
|
||||
queryObject={raw}
|
||||
pageSize={PAGE_SIZE}
|
||||
navigateTo={(row) => row.original.id}
|
||||
isLoading={isLoading}
|
||||
pagination
|
||||
search
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import { usePrompt } from "@medusajs/ui"
|
||||
import { useAdminDeletePriceList } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
|
||||
type PricingTableActionsProps = {
|
||||
priceList: PriceList
|
||||
}
|
||||
|
||||
export const PricingTableActions = ({
|
||||
priceList,
|
||||
}: PricingTableActionsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const { mutateAsync } = useAdminDeletePriceList(priceList.id)
|
||||
|
||||
const handleDelete = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("pricing.deletePriceListWarning", {
|
||||
name: priceList.name,
|
||||
}),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync()
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
to: `${priceList.id}/edit`,
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.delete"),
|
||||
onClick: handleDelete,
|
||||
icon: <Trash />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { PriceList } from "@medusajs/medusa"
|
||||
import { createColumnHelper } from "@tanstack/react-table"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
|
||||
import { getPriceListStatus } from "../../../common/utils"
|
||||
import { PricingTableActions } from "./pricing-table-actions"
|
||||
|
||||
const columnHelper = createColumnHelper<PriceList>()
|
||||
|
||||
export const usePricingTableColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("name", {
|
||||
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 }) => {
|
||||
const { color, text } = getPriceListStatus(t, row.original)
|
||||
|
||||
return <StatusCell color={color}>{text}</StatusCell>
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("customer_groups", {
|
||||
header: t("customerGroups.domain"),
|
||||
cell: (info) => info.getValue()?.length || "-",
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => <PricingTableActions priceList={row.original} />,
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { AdminGetPriceListPaginationParams } from "@medusajs/medusa"
|
||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||
|
||||
export const usePricingTableQuery = ({
|
||||
pageSize = 20,
|
||||
prefix,
|
||||
}: {
|
||||
pageSize?: number
|
||||
prefix?: string
|
||||
}) => {
|
||||
const raw = useQueryParams(["offset", "q"], prefix)
|
||||
|
||||
const searchParams: AdminGetPriceListPaginationParams = {
|
||||
limit: pageSize,
|
||||
offset: raw.offset ? Number(raw.offset) : 0,
|
||||
q: raw.q,
|
||||
}
|
||||
|
||||
return {
|
||||
searchParams,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingList as Component } from "./pricing-list"
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { PricingListTable } from "./components/pricing-list-table"
|
||||
|
||||
export const PricingList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<PricingListTable />
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingProductsAdd as Component } from "./pricing-products-add"
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
|
||||
export const PricingProductsAdd = () => {
|
||||
return <RouteFocusModal></RouteFocusModal>
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
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
@@ -0,0 +1 @@
|
||||
export * from "./edit-product-prices-form"
|
||||
@@ -0,0 +1 @@
|
||||
export { PricingProductsEdit as Component } from "./pricing-products-edit"
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user