diff --git a/packages/admin-next/dashboard/package.json b/packages/admin-next/dashboard/package.json index 85c4f11ae2..84b848f6d7 100644 --- a/packages/admin-next/dashboard/package.json +++ b/packages/admin-next/dashboard/package.json @@ -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" }, diff --git a/packages/admin-next/dashboard/src/components/grid/constants.ts b/packages/admin-next/dashboard/src/components/grid/constants.ts new file mode 100644 index 0000000000..333c81a870 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/grid/constants.ts @@ -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, +] diff --git a/packages/admin-next/dashboard/src/components/grid/data-grid-demo.tsx b/packages/admin-next/dashboard/src/components/grid/data-grid-demo.tsx deleted file mode 100644 index 8a2e336d2f..0000000000 --- a/packages/admin-next/dashboard/src/components/grid/data-grid-demo.tsx +++ /dev/null @@ -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 -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({ - 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 ( - - -
- -
-
- ) -} - -/** - * 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() - -const useColumns = () => { - const { t } = useTranslation() - - const colDefs: ColumnDef[] = useMemo(() => { - return [ - columnHelper.display({ - id: t("fields.title"), - header: "Title", - cell: ({ row, table }) => { - const entity = row.original - - if (isProduct(entity)) { - return ( - -
- - {entity.title} -
-
- ) - } - - return ( - } - 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 - } - - return ( - } - 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 - } - - return ( - } - 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 - } - - return ( - } - field={`products.${entity.product_id}.variants.${entity.id}.upc`} - /> - ) - }, - }), - ] - }, [t]) - - return colDefs -} 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 c9ba29d500..4644bbd8ae 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 @@ -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[] state: UseFormReturn 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) => { + 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 ( -
+
- extends FieldProps {} +interface BooleanCellProps + extends CellProps {} -export const BooleanField = ({ +export const BooleanCell = ({ field, meta, -}: BooleanFieldProps) => { +}: BooleanCellProps) => { const { control } = meta return ( diff --git a/packages/admin-next/dashboard/src/components/grid/grid-cells/common/boolean-cell/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/boolean-cell/index.ts new file mode 100644 index 0000000000..cda2339be4 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/boolean-cell/index.ts @@ -0,0 +1 @@ +export * from "./boolean-cell" diff --git a/packages/admin-next/dashboard/src/components/grid/grid-cells/common/currency-cell/currency-cell.tsx b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/currency-cell/currency-cell.tsx new file mode 100644 index 0000000000..a42b6cbcfc --- /dev/null +++ b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/currency-cell/currency-cell.tsx @@ -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 + extends CellProps { + currency: CurrencyDTO +} + +export const CurrencyCell = ({ currency, field, meta }: CurrencyCellProps) => { + const symbolRef = useRef(null) + // @ts-ignore - Type is wrong + const decimalScale = currency.decimal_digits + + const { control } = meta + + return ( + { + return ( +
+ + {currency.symbol_native} + + 0} + onValueChange={(_value, _name, values) => { + onChange(values?.value) + }} + {...rest} + /> +
+ ) + }} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/components/grid/grid-cells/common/currency-cell/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/currency-cell/index.ts new file mode 100644 index 0000000000..58a099a5f8 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/currency-cell/index.ts @@ -0,0 +1 @@ +export * from "./currency-cell" diff --git a/packages/admin-next/dashboard/src/components/grid/grid-cells/common/readonly-cell/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/readonly-cell/index.ts new file mode 100644 index 0000000000..367aa8b805 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/readonly-cell/index.ts @@ -0,0 +1 @@ +export * from "./readonly-cell" diff --git a/packages/admin-next/dashboard/src/components/grid/grid-cells/common/readonly-cell/readonly-cell.tsx b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/readonly-cell/readonly-cell.tsx new file mode 100644 index 0000000000..0f524bc2a3 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/readonly-cell/readonly-cell.tsx @@ -0,0 +1,14 @@ +import { PropsWithChildren } from "react" +import { GridCellType } from "../../../constants" + +export const ReadonlyCell = ({ children }: PropsWithChildren) => { + return ( +
+ {children} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/grid/grid-cells/common/text-cell/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/text-cell/index.ts new file mode 100644 index 0000000000..22f7bca960 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/text-cell/index.ts @@ -0,0 +1 @@ +export * from "./text-cell" diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/text-field.tsx b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/text-cell/text-cell.tsx similarity index 62% rename from packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/text-field.tsx rename to packages/admin-next/dashboard/src/components/grid/grid-cells/common/text-cell/text-cell.tsx index 1f640983bd..c4d1a9ee57 100644 --- a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/text-field.tsx +++ b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/text-cell/text-cell.tsx @@ -1,13 +1,13 @@ import { FieldValues } from "react-hook-form" -import { FieldProps } from "../../../types" +import { CellProps } from "../../../types" -interface TextFieldProps - extends FieldProps {} +interface TextCellProps + extends CellProps {} -export const TextField = ({ +export const TextCell = ({ field, meta, -}: TextFieldProps) => { +}: TextCellProps) => { const { register } = meta return ( diff --git a/packages/admin-next/dashboard/src/components/grid/grid-cells/common/void-cell/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/void-cell/index.ts new file mode 100644 index 0000000000..1015024549 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/void-cell/index.ts @@ -0,0 +1 @@ +export * from "./void-cell" diff --git a/packages/admin-next/dashboard/src/components/grid/grid-cells/common/void-cell/void-cell.tsx b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/void-cell/void-cell.tsx new file mode 100644 index 0000000000..11dd3ab6e2 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/grid/grid-cells/common/void-cell/void-cell.tsx @@ -0,0 +1,14 @@ +import { PropsWithChildren } from "react" +import { GridCellType } from "../../../constants" + +export const VoidCell = ({ children }: PropsWithChildren) => { + return ( +
+ {children} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/boolean-field/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/boolean-field/index.ts deleted file mode 100644 index 531987a0f7..0000000000 --- a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/boolean-field/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./boolean-field" diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/index.ts deleted file mode 100644 index 44064b65e8..0000000000 --- a/packages/admin-next/dashboard/src/components/grid/grid-fields/common/text-field/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./text-field" diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/display-field.tsx b/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/display-field.tsx deleted file mode 100644 index 4385adeb98..0000000000 --- a/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/display-field.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { PropsWithChildren } from "react" - -/** - * Field for displaying non-editable data in a grid. - */ -export const DisplayField = ({ children }: PropsWithChildren) => { - return ( -
- {children} -
- ) -} diff --git a/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/index.ts b/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/index.ts deleted file mode 100644 index b3463481e7..0000000000 --- a/packages/admin-next/dashboard/src/components/grid/grid-fields/non-interactive/display-field/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./display-field" diff --git a/packages/admin-next/dashboard/src/components/grid/types.ts b/packages/admin-next/dashboard/src/components/grid/types.ts index 49ed629b00..b0a10b5d7e 100644 --- a/packages/admin-next/dashboard/src/components/grid/types.ts +++ b/packages/admin-next/dashboard/src/components/grid/types.ts @@ -5,7 +5,7 @@ export type DataGridMeta = { control: Control } -export interface FieldProps { +export interface CellProps { field: Path meta: DataGridMeta } diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/common/created-at-cell/created-at-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/common/created-at-cell/created-at-cell.tsx index c36a04782b..50b480961b 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/common/created-at-cell/created-at-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/common/created-at-cell/created-at-cell.tsx @@ -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 + } + const value = new Date(date) value.setMinutes(value.getMinutes() - value.getTimezoneOffset()) diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/status-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/status-cell.tsx index 168a94117e..14a5f29a33 100644 --- a/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/status-cell.tsx +++ b/packages/admin-next/dashboard/src/components/table/table-cells/common/status-cell/status-cell.tsx @@ -7,10 +7,10 @@ type StatusCellProps = PropsWithChildren<{ export const StatusCell = ({ color, children }: StatusCellProps) => { return ( -
+
, + options?: Omit< + UseQueryOptions, + "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, + options?: Omit< + UseQueryOptions, + "queryKey" | "queryFn" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => client.priceLists.list(query), + queryKey: priceListsQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useCreatePriceList = ( + options?: UseMutationOptions +) => { + 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 +) => { + 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 +) => { + 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 +) => { + 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 +) => { + 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, + }) +} diff --git a/packages/admin-next/dashboard/src/hooks/table/columns/use-customer-group-table-columns.tsx b/packages/admin-next/dashboard/src/hooks/table/columns/use-customer-group-table-columns.tsx index 779d65252e..e0e6743dfd 100644 --- a/packages/admin-next/dashboard/src/hooks/table/columns/use-customer-group-table-columns.tsx +++ b/packages/admin-next/dashboard/src/hooks/table/columns/use-customer-group-table-columns.tsx @@ -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() +const columnHelper = createColumnHelper() export const useCustomerGroupTableColumns = () => { return useMemo( diff --git a/packages/admin-next/dashboard/src/lib/client/client.ts b/packages/admin-next/dashboard/src/lib/client/client.ts index 12e5cd94c1..f38dd0dfcc 100644 --- a/packages/admin-next/dashboard/src/lib/client/client.ts +++ b/packages/admin-next/dashboard/src/lib/client/client.ts @@ -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, } diff --git a/packages/admin-next/dashboard/src/lib/client/price-lists.ts b/packages/admin-next/dashboard/src/lib/client/price-lists.ts new file mode 100644 index 0000000000..9a25944a86 --- /dev/null +++ b/packages/admin-next/dashboard/src/lib/client/price-lists.ts @@ -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) { + return getRequest(`/admin/price-lists/${id}`, query) +} + +async function listPriceLists(query?: Record) { + return getRequest(`/admin/price-lists`, query) +} + +async function createPriceList(payload: CreatePriceListReq) { + return postRequest(`/admin/price-lists`, payload) +} + +async function updatePriceList(id: string, payload: UpdatePriceListReq) { + return postRequest(`/admin/price-lists/${id}`, payload) +} + +async function deletePriceList(id: string) { + return postRequest(`/admin/price-lists/${id}/delete`) +} + +async function addPriceListPrices(id: string, payload: AddPriceListPricesReq) { + return postRequest( + `/admin/price-lists/${id}/prices/batch/add`, + payload + ) +} + +async function removePriceListPrices( + id: string, + payload: DeletePriceListPricesReq +) { + return postRequest( + `/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, +} diff --git a/packages/admin-next/dashboard/src/lib/money-amount-helpers.ts b/packages/admin-next/dashboard/src/lib/money-amount-helpers.ts index a4de8bf37d..af0233c30d 100644 --- a/packages/admin-next/dashboard/src/lib/money-amount-helpers.ts +++ b/packages/admin-next/dashboard/src/lib/money-amount-helpers.ts @@ -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") } diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx index b68e0e02b8..8154065b29 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v1.tsx @@ -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, diff --git a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx index fec1a68be0..3908ac4ba3 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -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: { diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/components/edit-product-prices-form/edit-product-prices-form.tsx b/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/components/edit-product-prices-form/edit-product-prices-form.tsx deleted file mode 100644 index 5669462084..0000000000 --- a/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/components/edit-product-prices-form/edit-product-prices-form.tsx +++ /dev/null @@ -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 - -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 ( - -
- - - - -
-
- ) -} - -/** - * 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() - -const createRegionColum = (region: Region) => { - return columnHelper.display({ - id: region.id, - header: region.name, - cell: ({ row, table }) => { - const entity = row.original - - if (isProduct(entity)) { - return - } - - return ( - } - /> - ) - }, - }) -} - -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 - } - - return ( - } - /> - ) - }, - }) -} - -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 ( - -
- - {entity.title} -
-
- ) - } - - return ( - -
- {entity.title} -
-
- ) - }, - size: 350, - }), - ...regionColumns, - ...currencyColumns, - ], - [t, regionColumns, currencyColumns] - ) -} diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/components/edit-product-prices-form/index.ts b/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/components/edit-product-prices-form/index.ts deleted file mode 100644 index 9ab3a194af..0000000000 --- a/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/components/edit-product-prices-form/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./edit-product-prices-form" diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/index.ts b/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/index.ts deleted file mode 100644 index d3d036ef45..0000000000 --- a/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PricingProductsEdit as Component } from "./pricing-products-edit" diff --git a/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/pricing-products-edit.tsx b/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/pricing-products-edit.tsx deleted file mode 100644 index f38b4ab4db..0000000000 --- a/packages/admin-next/dashboard/src/routes/pricing/pricing-products-edit/pricing-products-edit.tsx +++ /dev/null @@ -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 ( - - {ready && ( - - )} - - ) -} diff --git a/packages/admin-next/dashboard/src/types/api-payloads.ts b/packages/admin-next/dashboard/src/types/api-payloads.ts index 7c42a99115..95c9a85b0f 100644 --- a/packages/admin-next/dashboard/src/types/api-payloads.ts +++ b/packages/admin-next/dashboard/src/types/api-payloads.ts @@ -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 diff --git a/packages/admin-next/dashboard/src/types/api-responses.ts b/packages/admin-next/dashboard/src/types/api-responses.ts index 74548c9fa5..6dd21f4036 100644 --- a/packages/admin-next/dashboard/src/types/api-responses.ts +++ b/packages/admin-next/dashboard/src/types/api-responses.ts @@ -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 diff --git a/packages/admin-next/dashboard/src/routes/pricing/common/constants.ts b/packages/admin-next/dashboard/src/v2-routes/pricing/common/constants.ts similarity index 100% rename from packages/admin-next/dashboard/src/routes/pricing/common/constants.ts rename to packages/admin-next/dashboard/src/v2-routes/pricing/common/constants.ts diff --git a/packages/admin-next/dashboard/src/v2-routes/pricing/common/hooks/use-price-list-grid-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/pricing/common/hooks/use-price-list-grid-columns.tsx new file mode 100644 index 0000000000..7388cab6e5 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/common/hooks/use-price-list-grid-columns.tsx @@ -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[] = + useMemo(() => { + return [ + columnHelper.display({ + id: t("fields.title"), + header: t("fields.title"), + cell: ({ row }) => { + const entity = row.original + + if (isProductRow(entity)) { + return ( + +
+ + {entity.title} +
+
+ ) + } + + return ( + +
+ {entity.title} +
+
+ ) + }, + }), + ...currencies.map((currency) => { + return columnHelper.display({ + header: `Price ${currency.code.toUpperCase()}`, + cell: ({ row, table }) => { + const entity = row.original + + if (isProductRow(entity)) { + return + } + + return ( + + ) + }, + }) + }), + ] + }, [t, currencies]) + + return colDefs +} diff --git a/packages/admin-next/dashboard/src/v2-routes/pricing/common/schemas.ts b/packages/admin-next/dashboard/src/v2-routes/pricing/common/schemas.ts new file mode 100644 index 0000000000..cc71de9d06 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/common/schemas.ts @@ -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 +> diff --git a/packages/admin-next/dashboard/src/routes/pricing/common/utils.ts b/packages/admin-next/dashboard/src/v2-routes/pricing/common/utils.ts similarity index 73% rename from packages/admin-next/dashboard/src/routes/pricing/common/utils.ts rename to packages/admin-next/dashboard/src/v2-routes/pricing/common/utils.ts index c5ec9a28f5..cd372026ce 100644 --- a/packages/admin-next/dashboard/src/routes/pricing/common/utils.ts +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/common/utils.ts @@ -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 +} diff --git a/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/components/price-list-configuration-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/components/price-list-configuration-form/index.ts new file mode 100644 index 0000000000..9f16da0fb6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/components/price-list-configuration-form/index.ts @@ -0,0 +1 @@ +export * from "./price-list-configuration-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/components/price-list-configuration-form/price-list-configuration-form.tsx b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/components/price-list-configuration-form/price-list-configuration-form.tsx new file mode 100644 index 0000000000..bbb9003849 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/components/price-list-configuration-form/price-list-configuration-form.tsx @@ -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>({ + 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 ( + +
+ + { + const handleSwitchChange = (checked: boolean) => { + if (!checked) { + onChange(null) + return + } + + const now = new Date() + + onChange(now) + } + + return ( + + +
+
+ + Price list has a start date? + + + Schedule the price list to activate in the future. + +
+ + + +
+ +
+ + {t("fields.startDate")} + + + + +
+
+ +
+
+ ) + }} + /> + + { + const handleSwitchChange = (checked: boolean) => { + if (!checked) { + onChange(null) + return + } + + const inAWeek = new Date( + new Date().setDate(new Date().getDate() + 7) + ) + + onChange(inAWeek) + } + + return ( + + +
+
+ + Price list has an end date? + + + Schedule the price list to deactivate in the future. + +
+ + + +
+ +
+ + {t("fields.endDate")} + + + + +
+
+ +
+
+ ) + }} + /> +
+ +
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/index.ts b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/index.ts new file mode 100644 index 0000000000..d2a1567c18 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/index.ts @@ -0,0 +1 @@ +export { PricingConfiguration as Component } from "./pricing-configuration" diff --git a/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/pricing-configuration.tsx b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/pricing-configuration.tsx new file mode 100644 index 0000000000..5b8eb53e27 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-configuration/pricing-configuration.tsx @@ -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 ( + + + {t("pricing.settings.editPriceListTitle")} + + {ready && } + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-create/components/pricing-create-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-create/components/pricing-create-form/index.ts new file mode 100644 index 0000000000..09560939d3 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-create/components/pricing-create-form/index.ts @@ -0,0 +1 @@ +export * from "./pricing-create-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-create/components/pricing-create-form/pricing-create-form.tsx b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-create/components/pricing-create-form/pricing-create-form.tsx new file mode 100644 index 0000000000..45806a2737 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-create/components/pricing-create-form/pricing-create-form.tsx @@ -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 + +const initialTabState: TabState = { + [Tab.DETAIL]: "in-progress", + [Tab.PRODUCT]: "not-started", + [Tab.PRICE]: "not-started", +} + +export const PricingCreateForm = () => { + const [tab, setTab] = useState(Tab.DETAIL) + const [tabState, setTabState] = useState(initialTabState) + + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm({ + 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[], + schema: z.ZodSchema + ) => { + form.clearErrors(fields) + + const values = fields.reduce((acc, key) => { + acc[key] = form.getValues(key) + return acc + }, {} as Record) + + 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 ( + + handleChangeTab(tab as Tab)} + className="flex h-full flex-col overflow-hidden" + > +
+ +
+
+ + + Details + + + Products + + + Prices + + +
+
+ + + + +
+
+
+ + + + + + + + + + + +
+
+
+ ) +} + +type PrimaryButtonProps = { + tab: Tab + next: (tab: Tab) => void + isLoading?: boolean +} + +const PrimaryButton = ({ tab, next, isLoading }: PrimaryButtonProps) => { + const { t } = useTranslation() + + if (tab === Tab.PRICE) { + return ( + + ) + } + + return ( + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-create/components/pricing-create-form/pricing-details-form.tsx b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-create/components/pricing-create-form/pricing-details-form.tsx new file mode 100644 index 0000000000..1667ba4e06 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/pricing/pricing-create/components/pricing-create-form/pricing-details-form.tsx @@ -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 +} + +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 ( + + +
+
+
+ Create Price List + + Create a new price list to manage the prices of your products. + +
+ { + return ( + +
+
+ {t("fields.type")} + + Choose the type of price list you want to create. + +
+ + + + + + +
+ +
+ ) + }} + /> +
+
+ { + return ( + + {t("fields.title")} + + + + + + ) + }} + /> +
+ { + return ( + + {t("fields.description")} + +