diff --git a/.changeset/calm-singers-return.md b/.changeset/calm-singers-return.md new file mode 100644 index 0000000000..e9230beb01 --- /dev/null +++ b/.changeset/calm-singers-return.md @@ -0,0 +1,6 @@ +--- +"@medusajs/client-types": patch +"@medusajs/medusa": patch +--- + +fix(medusa): Allows to filter price list products by multiple ids. diff --git a/packages/admin-next/dashboard/public/locales/en-US/translation.json b/packages/admin-next/dashboard/public/locales/en-US/translation.json index effe2c4628..77d7c84a1e 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -322,7 +322,35 @@ } }, "pricing": { - "domain": "Pricing" + "domain": "Pricing", + "deletePriceListWarning": "You are about to delete the price list {{name}}. This action cannot be undone.", + "status": { + "draft": "Draft", + "expired": "Expired", + "active": "Active", + "scheduled": "Scheduled" + }, + "type": { + "sale": "Sale", + "override": "Override" + }, + "settings": { + "typeHint": "Choose the type of price list you want to create.", + "saleTypeHint": "Sale prices are temporary price changes for products.", + "overrideTypeHint": "Overrides are usually used to create customer-specific prices.", + "editPriceListTitle": "Edit Price List", + "customerGroupsLabel": "Customer groups", + "priceOverridesLabel": "Price overrides", + "taxInclusivePricingHint": "When enabled all prices in the price list will be tax inclusive." + }, + "products": { + "deleteProductsPricesWarning_one": "You are about to delete {{count}} product price. This action cannot be undone.", + "deleteProductsPricesWarning_other": "You are about to delete {{count}} product prices. This action cannot be undone." + }, + "prices": { + "addPrices": "Add prices", + "editPrices": "Edit prices" + } }, "profile": { "domain": "Profile", diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx index 3d7cf16b8f..c9ba29d500 100644 --- a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx +++ b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid-root/data-grid-root.tsx @@ -487,14 +487,13 @@ export const DataGridRoot = < }, [handleMouseUp, handleCopy, handlePaste, handleCommandHistory]) return ( -
-
+
diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid.tsx b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid.tsx index 88d9e4b4a7..54445c2d58 100644 --- a/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid.tsx +++ b/packages/admin-next/dashboard/src/components/grid/data-grid/data-grid.tsx @@ -11,13 +11,9 @@ export const DataGrid = ({ isLoading, ...props }: DataGridProps) => { - return ( -
- {isLoading ? ( - - ) : ( - - )} -
+ return isLoading ? ( + + ) : ( + ) } diff --git a/packages/admin-next/dashboard/src/hooks/table/query/use-product-table-query.tsx b/packages/admin-next/dashboard/src/hooks/table/query/use-product-table-query.tsx index 5e0720dda5..1d813f94f0 100644 --- a/packages/admin-next/dashboard/src/hooks/table/query/use-product-table-query.tsx +++ b/packages/admin-next/dashboard/src/hooks/table/query/use-product-table-query.tsx @@ -26,6 +26,7 @@ export const useProductTableQuery = ({ "tags", "type_id", "status", + "id", ], prefix ) diff --git a/packages/admin-next/dashboard/src/hooks/use-form-prompt.tsx b/packages/admin-next/dashboard/src/hooks/use-form-prompt.tsx deleted file mode 100644 index 206df07caf..0000000000 --- a/packages/admin-next/dashboard/src/hooks/use-form-prompt.tsx +++ /dev/null @@ -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 -} diff --git a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx index a0a8d76262..557deb37e5 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx @@ -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"), + }, + ], }, ], }, diff --git a/packages/admin-next/dashboard/src/routes/pricing/common/constants.ts b/packages/admin-next/dashboard/src/routes/pricing/common/constants.ts new file mode 100644 index 0000000000..24218e56e8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/common/constants.ts @@ -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", +} diff --git a/packages/admin-next/dashboard/src/routes/pricing/common/utils.ts b/packages/admin-next/dashboard/src/routes/pricing/common/utils.ts new file mode 100644 index 0000000000..c5ec9a28f5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/common/utils.ts @@ -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, + } +} diff --git a/packages/admin-next/dashboard/src/routes/pricing/details/details.tsx b/packages/admin-next/dashboard/src/routes/pricing/details/details.tsx deleted file mode 100644 index 6e41789447..0000000000 --- a/packages/admin-next/dashboard/src/routes/pricing/details/details.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Container, Heading } from "@medusajs/ui"; - -export const PricingDetails = () => { - return ( -
- - Pricing - -
- ); -}; diff --git a/packages/admin-next/dashboard/src/routes/pricing/details/index.ts b/packages/admin-next/dashboard/src/routes/pricing/details/index.ts deleted file mode 100644 index 720fdcf6f8..0000000000 --- a/packages/admin-next/dashboard/src/routes/pricing/details/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PricingDetails as Component } from "./details"; diff --git a/packages/admin-next/dashboard/src/routes/pricing/list/index.ts b/packages/admin-next/dashboard/src/routes/pricing/list/index.ts deleted file mode 100644 index b2b32b4611..0000000000 --- a/packages/admin-next/dashboard/src/routes/pricing/list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PricingList as Component } from "./list"; diff --git a/packages/admin-next/dashboard/src/routes/pricing/list/list.tsx b/packages/admin-next/dashboard/src/routes/pricing/list/list.tsx deleted file mode 100644 index fe3df51b7e..0000000000 --- a/packages/admin-next/dashboard/src/routes/pricing/list/list.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Container, Heading } from "@medusajs/ui"; - -export const PricingList = () => { - return ( -
- - Pricing - -
- ); -}; diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-general-section/index.ts b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-general-section/index.ts new file mode 100644 index 0000000000..6f5f081067 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-general-section/index.ts @@ -0,0 +1 @@ +export * from "./pricing-general-section" diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-general-section/pricing-general-section.tsx b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-general-section/pricing-general-section.tsx new file mode 100644 index 0000000000..898cb13d0c --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-general-section/pricing-general-section.tsx @@ -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 ( + +
+ {priceList.name} +
+ {text} + , + }, + ], + }, + { + actions: [ + { + label: t("actions.delete"), + onClick: handleDelete, + icon: , + }, + ], + }, + ]} + /> +
+
+
+ + {t("fields.type")} + + + {type} + +
+
+ + {t("fields.description")} + + + {priceList.description} + +
+
+ + {t("pricing.settings.customerGroupsLabel")} + + + + {firstCustomerGroups.length > 0 + ? firstCustomerGroups.join(", ") + : "-"} + + {remainingCustomerGroups.length > 0 && ( + + {remainingCustomerGroups.map((cg) => ( +
  • {cg.name}
  • + ))} + + } + > + + {" "} + {t("general.plusCountMore", { + count: remainingCustomerGroups.length, + })} + +
    + )} +
    +
    +
    + + {t("pricing.settings.priceOverridesLabel")} + + + {overrideCount || "-"} + +
    +
    + ) +} diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-product-section/index.ts b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-product-section/index.ts new file mode 100644 index 0000000000..39141c59a2 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-product-section/index.ts @@ -0,0 +1 @@ +export * from "./pricing-product-section" diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-product-section/pricing-product-section.tsx b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-product-section/pricing-product-section.tsx new file mode 100644 index 0000000000..49919df59d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/components/pricing-product-section/pricing-product-section.tsx @@ -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({}) + + 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 ( + +
    + {t("products.domain")} + , + }, + { + label: t("pricing.prices.editPrices"), + to: "products/edit", + icon: , + }, + ], + }, + ]} + /> +
    + `/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} + /> +
    + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = useProductTableColumns() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + ...base, + ], + [base] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/index.ts b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/index.ts new file mode 100644 index 0000000000..aa7e1461c8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/index.ts @@ -0,0 +1 @@ +export { PricingDetail as Component } from "./pricing-detail" diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/pricing-detail.tsx b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/pricing-detail.tsx new file mode 100644 index 0000000000..91d06a9dd5 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/pricing-detail/pricing-detail.tsx @@ -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
    Loading...
    + } + + if (isError) { + throw error + } + + return ( +
    + + + + +
    + ) +} diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-edit/components/edit-price-list-form/edit-price-list-form.tsx b/packages/admin-next/dashboard/src/routes/pricing/pricing-edit/components/edit-price-list-form/edit-price-list-form.tsx new file mode 100644 index 0000000000..6abde24142 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/pricing/pricing-edit/components/edit-price-list-form/edit-price-list-form.tsx @@ -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>({ + 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 ( + +
    + + { + return ( + +
    + {t("fields.type")} + {t("pricing.settings.typeHint")} +
    + + + + + + +
    + ) + }} + /> +
    + { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.description")} + +