From 874d511e1358211af27a72b9148406954360d028 Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Sun, 14 Jul 2024 18:03:44 +0200 Subject: [PATCH] feat: Add tax inclusivity management to currenices (#8112) --- .../src/hooks/api/price-preferences.tsx | 4 +- .../dashboard/src/hooks/api/regions.tsx | 16 +++ .../dashboard/src/hooks/api/store.tsx | 8 ++ .../dashboard/src/i18n/translations/en.json | 5 +- .../hooks/use-currencies-table-columns.tsx | 4 +- .../add-currencies-form.tsx | 124 ++++++++++++----- .../store-currency-section.tsx | 125 +++++++++++++++--- .../__tests__/check-circle.spec.tsx | 16 +++ .../icons/src/components/check-circle.tsx | 33 +++++ .../icons/src/components/index.ts | 1 + .../pricing/src/services/pricing-module.ts | 44 ++++-- 11 files changed, 316 insertions(+), 64 deletions(-) create mode 100644 packages/design-system/icons/src/components/__tests__/check-circle.spec.tsx create mode 100644 packages/design-system/icons/src/components/check-circle.tsx diff --git a/packages/admin-next/dashboard/src/hooks/api/price-preferences.tsx b/packages/admin-next/dashboard/src/hooks/api/price-preferences.tsx index bdc49b41fd..c3fc281110 100644 --- a/packages/admin-next/dashboard/src/hooks/api/price-preferences.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/price-preferences.tsx @@ -31,7 +31,7 @@ export const usePricePreference = ( ) => { const { data, ...rest } = useQuery({ queryFn: () => sdk.admin.pricePreference.retrieve(id, query), - queryKey: pricePreferencesQueryKeys.detail(id), + queryKey: pricePreferencesQueryKeys.detail(), ...options, }) @@ -52,7 +52,7 @@ export const usePricePreferences = ( ) => { const { data, ...rest } = useQuery({ queryFn: () => sdk.admin.pricePreference.list(query), - queryKey: pricePreferencesQueryKeys.list(query), + queryKey: pricePreferencesQueryKeys.list(), ...options, }) diff --git a/packages/admin-next/dashboard/src/hooks/api/regions.tsx b/packages/admin-next/dashboard/src/hooks/api/regions.tsx index f491bc4889..c2053d4206 100644 --- a/packages/admin-next/dashboard/src/hooks/api/regions.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/regions.tsx @@ -9,6 +9,7 @@ import { import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory } from "../../lib/query-key-factory" +import { pricePreferencesQueryKeys } from "./price-preferences" const REGIONS_QUERY_KEY = "regions" as const export const regionsQueryKeys = queryKeysFactory(REGIONS_QUERY_KEY) @@ -67,6 +68,14 @@ export const useCreateRegion = ( mutationFn: (payload) => sdk.admin.region.create(payload), onSuccess: (data, variables, context) => { queryClient.invalidateQueries({ queryKey: regionsQueryKeys.lists() }) + + queryClient.invalidateQueries({ + queryKey: pricePreferencesQueryKeys.list(), + }) + queryClient.invalidateQueries({ + queryKey: pricePreferencesQueryKeys.details(), + }) + options?.onSuccess?.(data, variables, context) }, ...options, @@ -87,6 +96,13 @@ export const useUpdateRegion = ( queryClient.invalidateQueries({ queryKey: regionsQueryKeys.lists() }) queryClient.invalidateQueries({ queryKey: regionsQueryKeys.detail(id) }) + queryClient.invalidateQueries({ + queryKey: pricePreferencesQueryKeys.list(), + }) + queryClient.invalidateQueries({ + queryKey: pricePreferencesQueryKeys.details(), + }) + options?.onSuccess?.(data, variables, context) }, ...options, diff --git a/packages/admin-next/dashboard/src/hooks/api/store.tsx b/packages/admin-next/dashboard/src/hooks/api/store.tsx index 7bd3465e67..b1a0dfbcaf 100644 --- a/packages/admin-next/dashboard/src/hooks/api/store.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/store.tsx @@ -11,6 +11,7 @@ import { HttpTypes } from "@medusajs/types" import { sdk } from "../../lib/client" import { queryClient } from "../../lib/query-client" import { queryKeysFactory } from "../../lib/query-key-factory" +import { pricePreferencesQueryKeys } from "./price-preferences" const STORE_QUERY_KEY = "store" as const export const storeQueryKeys = queryKeysFactory(STORE_QUERY_KEY) @@ -67,7 +68,14 @@ export const useUpdateStore = ( return useMutation({ mutationFn: (payload) => sdk.admin.store.update(id, payload), onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ + queryKey: pricePreferencesQueryKeys.list(), + }) + queryClient.invalidateQueries({ + queryKey: pricePreferencesQueryKeys.details(), + }) queryClient.invalidateQueries({ queryKey: storeQueryKeys.details() }) + options?.onSuccess?.(data, variables, context) }, ...options, diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json index 28a2013089..b0b54e6f3a 100644 --- a/packages/admin-next/dashboard/src/i18n/translations/en.json +++ b/packages/admin-next/dashboard/src/i18n/translations/en.json @@ -1710,6 +1710,8 @@ "inviteLinkTemplate": "Invite link template", "currencies": "Currencies", "addCurrencies": "Add currencies", + "enableTaxInclusivePricing": "Enable tax inclusive pricing", + "disableTaxInclusivePricing": "Disable tax inclusive pricing", "removeCurrencyWarning_one": "You are about to remove {{count}} currency from your store. Ensure that you have removed all prices using the currency before proceeding.", "removeCurrencyWarning_other": "You are about to remove {{count}} currencies from your store. Ensure that you have removed all prices using the currencies before proceeding.", "currencyAlreadyAdded": "The currency has already been added to your store.", @@ -1719,7 +1721,8 @@ "toast": { "update": "Store successfully updated", "currenciesUpdated": "Currencies updated successfully", - "currenciesRemoved": "Removed currencies from the store successfully" + "currenciesRemoved": "Removed currencies from the store successfully", + "updatedTaxInclusivitySuccessfully": "Tax inclusive pricing updated successfully" } }, "regions": { diff --git a/packages/admin-next/dashboard/src/routes/store/common/hooks/use-currencies-table-columns.tsx b/packages/admin-next/dashboard/src/routes/store/common/hooks/use-currencies-table-columns.tsx index f6c458a743..8d9499b23f 100644 --- a/packages/admin-next/dashboard/src/routes/store/common/hooks/use-currencies-table-columns.tsx +++ b/packages/admin-next/dashboard/src/routes/store/common/hooks/use-currencies-table-columns.tsx @@ -1,9 +1,9 @@ -import { CurrencyDTO } from "@medusajs/types" +import { HttpTypes } from "@medusajs/types" import { createColumnHelper } from "@tanstack/react-table" import { useMemo } from "react" import { useTranslation } from "react-i18next" -const columnHelper = createColumnHelper() +const columnHelper = createColumnHelper() export const useCurrenciesTableColumns = () => { const { t } = useTranslation() diff --git a/packages/admin-next/dashboard/src/routes/store/store-add-currencies/components/add-currencies-form/add-currencies-form.tsx b/packages/admin-next/dashboard/src/routes/store/store-add-currencies/components/add-currencies-form/add-currencies-form.tsx index 53ec9678f3..5828f52354 100644 --- a/packages/admin-next/dashboard/src/routes/store/store-add-currencies/components/add-currencies-form/add-currencies-form.tsx +++ b/packages/admin-next/dashboard/src/routes/store/store-add-currencies/components/add-currencies-form/add-currencies-form.tsx @@ -1,16 +1,15 @@ -import { Currency } from "@medusajs/medusa" -import { Button, Checkbox, Hint, toast, Tooltip } from "@medusajs/ui" +import { Button, Checkbox, Hint, Switch, toast, Tooltip } from "@medusajs/ui" import { createColumnHelper, OnChangeFn, RowSelectionState, } from "@tanstack/react-table" -import { useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import * as zod from "zod" import { zodResolver } from "@hookform/resolvers/zod" -import { StoreDTO } from "@medusajs/types" +import { HttpTypes } from "@medusajs/types" import { keepPreviousData } from "@tanstack/react-query" import { useForm } from "react-hook-form" import { @@ -23,13 +22,15 @@ import { useUpdateStore } from "../../../../../hooks/api/store" import { useDataTable } from "../../../../../hooks/use-data-table" import { useCurrenciesTableColumns } from "../../../common/hooks/use-currencies-table-columns" import { useCurrenciesTableQuery } from "../../../common/hooks/use-currencies-table-query" +import { usePricePreferences } from "../../../../../hooks/api/price-preferences" type AddCurrenciesFormProps = { - store: StoreDTO + store: HttpTypes.AdminStore } const AddCurrenciesSchema = zod.object({ currencies: zod.array(zod.string()).min(1), + pricePreferences: zod.record(zod.boolean()), }) const PAGE_SIZE = 50 @@ -39,30 +40,6 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => { const { t } = useTranslation() const { handleSuccess } = useRouteModal() - const form = useForm>({ - defaultValues: { - currencies: [], - }, - resolver: zodResolver(AddCurrenciesSchema), - }) - - const { setValue } = form - - const [rowSelection, setRowSelection] = useState({}) - - const updater: OnChangeFn = (fn) => { - const updated = typeof fn === "function" ? fn(rowSelection) : fn - - const ids = Object.keys(updated) - - setValue("currencies", ids, { - shouldDirty: true, - shouldTouch: true, - }) - - setRowSelection(updated) - } - const { raw, searchParams } = useCurrenciesTableQuery({ pageSize: 50, prefix: PREFIX, @@ -78,10 +55,61 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => { placeholderData: keepPreviousData, }) + const { + price_preferences: pricePreferences, + isPending: isPricePreferencesPending, + isError: isPricePreferencesError, + error: pricePreferencesError, + } = usePricePreferences({ + attribute: "currency_code", + value: store.supported_currencies?.map((c) => c.currency_code), + }) + + const form = useForm>({ + defaultValues: { + currencies: [], + pricePreferences: {}, + }, + resolver: zodResolver(AddCurrenciesSchema), + }) + + const [rowSelection, setRowSelection] = useState({}) + + const { setValue, watch } = form + const pricePreferenceValues = watch("pricePreferences") + + const updater: OnChangeFn = (fn) => { + const updated = typeof fn === "function" ? fn(rowSelection) : fn + + const ids = Object.keys(updated) + setValue("currencies", ids, { + shouldDirty: true, + shouldTouch: true, + }) + + setRowSelection(updated) + } + const preSelectedRows = store.supported_currencies?.map((c) => c.currency_code) ?? [] - const columns = useColumns() + const setPricePreferences = useCallback( + (values: Record) => { + setValue("pricePreferences", values) + }, + [setValue] + ) + + useEffect(() => { + setPricePreferences( + pricePreferences?.reduce((acc: Record, curr) => { + acc[curr.value] = curr.is_tax_inclusive + return acc + }, {}) + ) + }, [pricePreferences, setPricePreferences]) + + const columns = useColumns(pricePreferenceValues, setPricePreferences) const { table } = useDataTable({ data: currencies ?? [], @@ -118,8 +146,7 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => { supported_currencies: currencies.map((c) => ({ currency_code: c, is_default: c === defaultCurrency, - // TODO: Add UI to manage this - is_tax_inclsuive: false, + is_tax_inclusive: data.pricePreferences[c], })), }, { @@ -185,9 +212,12 @@ export const AddCurrenciesForm = ({ store }: AddCurrenciesFormProps) => { ) } -const columnHelper = createColumnHelper() +const columnHelper = createColumnHelper() -const useColumns = () => { +const useColumns = ( + pricePreferences: Record, + setPricePreferences: any +) => { const { t } = useTranslation() const base = useCurrenciesTableColumns() @@ -236,7 +266,31 @@ const useColumns = () => { }, }), ...base, + columnHelper.display({ + id: "select", + header: () => ( +
+ {t("fields.taxInclusivePricing")} +
+ ), + cell: ({ row }) => { + const isPreSelected = !row.getCanSelect() + const isTaxInclusive = pricePreferences[row.original.code] + return ( + { + setPricePreferences({ + ...pricePreferences, + [row.original.code]: val, + }) + }} + /> + ) + }, + }), ], - [t, base] + [t, base, pricePreferences, setPricePreferences] ) } diff --git a/packages/admin-next/dashboard/src/routes/store/store-detail/components/store-currency-section/store-currencies-section.tsx/store-currency-section.tsx b/packages/admin-next/dashboard/src/routes/store/store-detail/components/store-currency-section/store-currencies-section.tsx/store-currency-section.tsx index fbec5a1915..a3b3e49a0c 100644 --- a/packages/admin-next/dashboard/src/routes/store/store-detail/components/store-currency-section/store-currencies-section.tsx/store-currency-section.tsx +++ b/packages/admin-next/dashboard/src/routes/store/store-detail/components/store-currency-section/store-currencies-section.tsx/store-currency-section.tsx @@ -1,5 +1,5 @@ -import { Plus, Trash } from "@medusajs/icons" -import { CurrencyDTO, StoreCurrencyDTO } from "@medusajs/types" +import { CheckCircle, Plus, Trash, XCircle } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" import { Checkbox, CommandBar, @@ -20,6 +20,8 @@ import { useDataTable } from "../../../../../../hooks/use-data-table" import { ExtendedStoreDTO } from "../../../../../../types/api-responses" import { useCurrenciesTableColumns } from "../../../../common/hooks/use-currencies-table-columns" import { useCurrenciesTableQuery } from "../../../../common/hooks/use-currencies-table-query" +import { usePricePreferences } from "../../../../../../hooks/api/price-preferences" +import { StatusCell } from "../../../../../../components/table/table-cells/common/status-cell" type StoreCurrencySectionProps = { store: ExtendedStoreDTO @@ -32,7 +34,13 @@ export const StoreCurrencySection = ({ store }: StoreCurrencySectionProps) => { const { searchParams, raw } = useCurrenciesTableQuery({ pageSize: PAGE_SIZE }) - const { currencies, count, isPending, isError, error } = useCurrencies( + const { + currencies, + count, + isPending: isCurrenciesPending, + isError: isCurrenciesError, + error: currenciesError, + } = useCurrencies( { code: store.supported_currencies?.map((c) => c.currency_code), ...searchParams, @@ -43,10 +51,33 @@ export const StoreCurrencySection = ({ store }: StoreCurrencySectionProps) => { } ) + const { + price_preferences: pricePreferences, + isPending: isPricePreferencesPending, + isError: isPricePreferencesError, + error: pricePreferencesError, + } = usePricePreferences( + { + attribute: "currency_code", + value: store.supported_currencies?.map((c) => c.currency_code), + }, + { + enabled: !!store.supported_currencies?.length, + } + ) + const columns = useColumns() + const prefMap = useMemo(() => { + return new Map(pricePreferences?.map((pref) => [pref.value!, pref])) + }, [pricePreferences]) + + const withTaxInclusivity = currencies?.map((c) => ({ + ...c, + is_tax_inclusive: prefMap.get(c.code)?.is_tax_inclusive, + })) const { table } = useDataTable({ - data: currencies ?? [], + data: withTaxInclusivity ?? [], columns, count: count, getRowId: (row) => row.code, @@ -62,6 +93,7 @@ export const StoreCurrencySection = ({ store }: StoreCurrencySectionProps) => { supportedCurrencies: store.supported_currencies, defaultCurrencyCode: store.supported_currencies?.find((c) => c.is_default) ?.currency_code, + preferencesMap: prefMap, }, }) @@ -104,10 +136,16 @@ export const StoreCurrencySection = ({ store }: StoreCurrencySectionProps) => { ) } - if (isError) { - throw error + if (isCurrenciesError) { + throw currenciesError } + if (isPricePreferencesError) { + throw pricePreferencesError + } + + const isLoading = isCurrenciesPending || isPricePreferencesPending + return (
@@ -134,7 +172,7 @@ export const StoreCurrencySection = ({ store }: StoreCurrencySectionProps) => { pageSize={PAGE_SIZE} columns={columns} count={!store.supported_currencies?.length ? 0 : count} - isLoading={!store.supported_currencies?.length ? false : isPending} + isLoading={!store.supported_currencies?.length ? false : isLoading} queryObject={raw} /> @@ -161,14 +199,15 @@ const CurrencyActions = ({ currency, supportedCurrencies, defaultCurrencyCode, + preferencesMap, }: { storeId: string - currency: CurrencyDTO - supportedCurrencies: StoreCurrencyDTO[] + currency: HttpTypes.AdminCurrency + supportedCurrencies: HttpTypes.AdminStoreCurrency[] defaultCurrencyCode: string + preferencesMap: Map }) => { const { mutateAsync } = useUpdateStore(storeId) - const { t } = useTranslation() const prompt = usePrompt() @@ -205,6 +244,31 @@ const CurrencyActions = ({ ) } + const handleToggleTaxInclusivity = async () => { + await mutateAsync( + { + supported_currencies: supportedCurrencies.map((c) => { + const pref = preferencesMap.get(c.currency_code) + return { + ...c, + is_tax_inclusive: + c.currency_code === currency.code + ? !pref?.is_tax_inclusive + : undefined, + } + }), + }, + { + onSuccess: () => { + toast.success(t("store.toast.updatedTaxInclusivitySuccessfully")) + }, + onError: (e) => { + toast.error(e.message) + }, + } + ) + } + return ( + ) : ( + + ), + label: preferencesMap.get(currency.code)?.is_tax_inclusive + ? t("store.disableTaxInclusivePricing") + : t("store.enableTaxInclusivePricing"), + onClick: handleToggleTaxInclusivity, + }, ], }, ]} @@ -223,10 +298,13 @@ const CurrencyActions = ({ ) } -const columnHelper = createColumnHelper() +const columnHelper = createColumnHelper< + HttpTypes.AdminCurrency & { is_tax_inclusive?: boolean } +>() const useColumns = () => { const base = useCurrenciesTableColumns() + const { t } = useTranslation() return useMemo( () => [ @@ -259,14 +337,30 @@ const useColumns = () => { }, }), ...base, + columnHelper.accessor("is_tax_inclusive", { + header: t("fields.taxInclusivePricing"), + cell: ({ getValue }) => { + const isTaxInclusive = getValue() + return ( + + {isTaxInclusive ? t("fields.true") : t("fields.false")} + + ) + }, + }), columnHelper.display({ id: "actions", cell: ({ row, table }) => { - const { supportedCurrencies, storeId, defaultCurrencyCode } = table - .options.meta as { - supportedCurrencies: StoreCurrencyDTO[] + const { + supportedCurrencies, + storeId, + defaultCurrencyCode, + preferencesMap, + } = table.options.meta as { + supportedCurrencies: HttpTypes.AdminStoreCurrency[] storeId: string defaultCurrencyCode: string + preferencesMap: Map } return ( @@ -275,11 +369,12 @@ const useColumns = () => { currency={row.original} supportedCurrencies={supportedCurrencies} defaultCurrencyCode={defaultCurrencyCode} + preferencesMap={preferencesMap} /> ) }, }), ], - [base] + [base, t] ) } diff --git a/packages/design-system/icons/src/components/__tests__/check-circle.spec.tsx b/packages/design-system/icons/src/components/__tests__/check-circle.spec.tsx new file mode 100644 index 0000000000..8e58c22e51 --- /dev/null +++ b/packages/design-system/icons/src/components/__tests__/check-circle.spec.tsx @@ -0,0 +1,16 @@ +import * as React from "react" +import { cleanup, render, screen } from "@testing-library/react" + +import CheckCircle from "../check-circle" + +describe("CheckCircle", () => { + it("should render the icon without errors", async () => { + render() + + const svgElement = screen.getByTestId("icon") + + expect(svgElement).toBeInTheDocument() + + cleanup() + }) +}) diff --git a/packages/design-system/icons/src/components/check-circle.tsx b/packages/design-system/icons/src/components/check-circle.tsx new file mode 100644 index 0000000000..f2fb872d1f --- /dev/null +++ b/packages/design-system/icons/src/components/check-circle.tsx @@ -0,0 +1,33 @@ +import * as React from "react" +import type { IconProps } from "../types" +const CheckCircle = React.forwardRef( + ({ color = "currentColor", ...props }, ref) => { + return ( + + + + + ) + } +) +CheckCircle.displayName = "CheckCircle" +export default CheckCircle diff --git a/packages/design-system/icons/src/components/index.ts b/packages/design-system/icons/src/components/index.ts index 8d7579201b..5ab2c58de8 100644 --- a/packages/design-system/icons/src/components/index.ts +++ b/packages/design-system/icons/src/components/index.ts @@ -66,6 +66,7 @@ export { default as ChatBubbleLeftRight } from "./chat-bubble-left-right" export { default as ChatBubble } from "./chat-bubble" export { default as CheckCircleMiniSolid } from "./check-circle-mini-solid" export { default as CheckCircleSolid } from "./check-circle-solid" +export { default as CheckCircle } from "./check-circle" export { default as CheckMini } from "./check-mini" export { default as Check } from "./check" export { default as ChevronDoubleLeftMiniSolid } from "./chevron-double-left-mini-solid" diff --git a/packages/modules/pricing/src/services/pricing-module.ts b/packages/modules/pricing/src/services/pricing-module.ts index 71fcd1d5a6..65c6896bdb 100644 --- a/packages/modules/pricing/src/services/pricing-module.ts +++ b/packages/modules/pricing/src/services/pricing-module.ts @@ -737,8 +737,9 @@ export default class PricingModuleService | PricingTypes.CreatePricePreferenceDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { - const preferences = await this.pricePreferenceService_.create( - data, + const normalized = Array.isArray(data) ? data : [data] + const preferences = await this.createPricePreferences_( + normalized, sharedContext ) @@ -777,14 +778,10 @@ export default class PricingModuleService const operations: Promise[] = [] if (forCreate.length) { - operations.push( - this.pricePreferenceService_.create(forCreate, sharedContext) - ) + operations.push(this.createPricePreferences_(forCreate, sharedContext)) } if (forUpdate.length) { - operations.push( - this.pricePreferenceService_.update(forUpdate, sharedContext) - ) + operations.push(this.updatePricePreferences_(forUpdate, sharedContext)) } const result = (await promiseAll(operations)).flat() @@ -833,7 +830,7 @@ export default class PricingModuleService })) } - const updateResult = await this.pricePreferenceService_.update( + const updateResult = await this.updatePricePreferences_( normalizedInput, sharedContext ) @@ -845,6 +842,35 @@ export default class PricingModuleService return isString(idOrSelector) ? pricePreferences[0] : pricePreferences } + @InjectTransactionManager("baseRepository_") + protected async createPricePreferences_( + data: PricingTypes.CreatePricePreferenceDTO[], + @MedusaContext() sharedContext: Context = {} + ) { + const preferences = await this.pricePreferenceService_.create( + data.map((d) => ({ + ...d, + is_tax_inclusive: d.is_tax_inclusive ?? false, + })), + sharedContext + ) + + return preferences + } + + @InjectTransactionManager("baseRepository_") + protected async updatePricePreferences_( + data: PricingTypes.UpdatePricePreferenceDTO[], + @MedusaContext() sharedContext: Context = {} + ) { + const preferences = await this.pricePreferenceService_.update( + data, + sharedContext + ) + + return preferences + } + @InjectTransactionManager("baseRepository_") protected async createPriceSets_( data: PricingTypes.CreatePriceSetDTO[],