diff --git a/.changeset/wicked-days-buy.md b/.changeset/wicked-days-buy.md new file mode 100644 index 0000000000..5d0ff80ff7 --- /dev/null +++ b/.changeset/wicked-days-buy.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(medusa,types): added tax flows end to end diff --git a/integration-tests/modules/__tests__/tax/admin/tax.spec.ts b/integration-tests/modules/__tests__/tax/admin/tax.spec.ts index 6172f4da11..8ce08d417b 100644 --- a/integration-tests/modules/__tests__/tax/admin/tax.spec.ts +++ b/integration-tests/modules/__tests__/tax/admin/tax.spec.ts @@ -1,8 +1,8 @@ -import { ITaxModuleService } from "@medusajs/types" import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ITaxModuleService } from "@medusajs/types" -import { createAdminUser } from "../../../../helpers/create-admin-user" import { medusaIntegrationTestRunner } from "medusa-test-utils" +import { createAdminUser } from "../../../../helpers/create-admin-user" jest.setTimeout(50000) @@ -58,6 +58,8 @@ medusaIntegrationTestRunner({ updated_at: expect.any(String), deleted_at: null, created_by: null, + rules: [], + tax_region: expect.any(Object), }, }) }) @@ -91,6 +93,9 @@ medusaIntegrationTestRunner({ created_by: "admin_user", provider_id: null, metadata: null, + children: [], + parent: null, + tax_rates: expect.any(Array), }, }) @@ -121,6 +126,13 @@ medusaIntegrationTestRunner({ deleted_at: null, created_by: "admin_user", is_combinable: false, + tax_region: expect.any(Object), + rules: [ + expect.objectContaining({ + reference: "product", + reference_id: "prod_1234", + }), + ], }, }) @@ -147,6 +159,11 @@ medusaIntegrationTestRunner({ created_by: "admin_user", metadata: null, provider_id: null, + children: [], + tax_rates: [], + parent: expect.objectContaining({ + id: usRegionId, + }), }, }) @@ -219,6 +236,9 @@ medusaIntegrationTestRunner({ created_by: "admin_user", provider_id: null, metadata: null, + children: [], + parent: null, + tax_rates: expect.any(Array), }, }) @@ -249,6 +269,13 @@ medusaIntegrationTestRunner({ deleted_at: null, created_by: "admin_user", is_combinable: false, + tax_region: expect.any(Object), + rules: [ + expect.objectContaining({ + reference: "product", + reference_id: "prod_1234", + }), + ], }, }) @@ -279,6 +306,13 @@ medusaIntegrationTestRunner({ updated_at: expect.any(String), created_by: "admin_user", is_combinable: true, + tax_region: expect.any(Object), + rules: [ + expect.objectContaining({ + reference: "product", + reference_id: "prod_1234", + }), + ], }, }) }) @@ -331,6 +365,43 @@ medusaIntegrationTestRunner({ expect(rates[0].deleted_at).not.toBeNull() }) + it("can retrieve a tax region", async () => { + const region = await service.createTaxRegions({ + country_code: "us", + }) + + const rate = await service.create({ + tax_region_id: region.id, + code: "test", + rate: 2.5, + name: "Test Rate", + }) + + const response = await api.get( + `/admin/tax-regions/${region.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + tax_region: { + id: region.id, + country_code: "us", + province_code: null, + parent_id: null, + provider_id: null, + created_by: null, + created_at: expect.any(String), + updated_at: expect.any(String), + tax_rates: expect.any(Array), + deleted_at: null, + metadata: null, + children: [], + parent: null, + }, + }) + }) + it("can create a tax region and delete it", async () => { const regionRes = await api.post( `/admin/tax-regions`, diff --git a/packages/admin-next/dashboard/public/locales/$schema.json b/packages/admin-next/dashboard/public/locales/$schema.json index 98af3952a9..7214957f47 100644 --- a/packages/admin-next/dashboard/public/locales/$schema.json +++ b/packages/admin-next/dashboard/public/locales/$schema.json @@ -140,6 +140,27 @@ }, "required": ["domain"] }, + "taxRegions": { + "type": "object", + "properties": { + "domain": { + "type": "string" + } + }, + "required": ["domain"] + }, + "taxRates": { + "type": "object", + "properties": { + "domain": { + "type": "string" + }, + "fields": { + "type": "object" + } + }, + "required": ["domain"] + }, "campaigns": { "type": "object", "properties": { 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 ead37f3bec..9f7cf35383 100644 --- a/packages/admin-next/dashboard/public/locales/en-US/translation.json +++ b/packages/admin-next/dashboard/public/locales/en-US/translation.json @@ -633,6 +633,49 @@ "disabled": "Disabled" } }, + "taxRates": { + "domain": "Tax Rates", + "fields": { + "isCombinable": "Is combinable?", + "appliesTo": "Tax Rate applies to", + "customer_groups": "Customer Group", + "product_collections": "Product Collection", + "product_tags": "Product Tag", + "product_types": "Product Type", + "products": "Product" + }, + "edit": { + "title": "Edit Tax Rate", + "description": "Edits tax rate for a tax region" + }, + "create": { + "title": "Create Tax Rate Override", + "description": "Creates tax rate overrides for a tax region" + } + }, + "taxRegions": { + "domain": "Tax Regions", + "description": "Manage your region's tax structure", + "create": { + "title": "Create Tax Region", + "description": "Creates a tax region with default tax rate" + }, + "create-child": { + "title": "Create Default Rate for Province", + "description": "Creates a tax region for a province with default tax rate" + }, + "removeWarning": "You are about to remove {{tax_region_name}}. This action cannot be undone.", + "fields": { + "rate": { + "name": "Rate", + "hint": "Tax rate to apply for a region or province" + }, + "is_combinable": { + "name": "Is combinable", + "hint": "If this tax rate can be combined with the default rate from province or parent" + } + } + }, "promotions": { "domain": "Promotions", "fields": { @@ -907,6 +950,7 @@ }, "taxes": { "domain": "Tax Regions", + "domainDescription": "Manage your tax region", "countries": { "taxCountriesHint": "Tax settings apply to the listed countries." }, @@ -1123,6 +1167,7 @@ "amount": "Amount", "refundAmount": "Refund amount", "name": "Name", + "default": "Default", "lastName": "Last Name", "firstName": "First Name", "title": "Title", @@ -1247,6 +1292,7 @@ "quantity": "Quantity", "variant": "Variant", "id": "ID", + "parent": "Parent", "minSubtotal": "Min. Subtotal", "maxSubtotal": "Max. Subtotal", "shippingProfile": "Shipping Profile", diff --git a/packages/admin-next/dashboard/src/components/common/date/index.ts b/packages/admin-next/dashboard/src/components/common/date/index.ts new file mode 100644 index 0000000000..98cb13cafc --- /dev/null +++ b/packages/admin-next/dashboard/src/components/common/date/index.ts @@ -0,0 +1,11 @@ +import format from "date-fns/format" + +export function formatDate(date: string | Date) { + const value = new Date(date) + value.setMinutes(value.getMinutes() - value.getTimezoneOffset()) + + const hour12 = Intl.DateTimeFormat().resolvedOptions().hour12 + const timestampFormat = hour12 ? "dd MMM yyyy hh:MM a" : "dd MMM yyyy HH:MM" + + return format(value, timestampFormat) +} diff --git a/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/data-table-query.tsx b/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/data-table-query.tsx index 60cfe1ba0e..6cf99ee531 100644 --- a/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/data-table-query.tsx +++ b/packages/admin-next/dashboard/src/components/table/data-table/data-table-query/data-table-query.tsx @@ -17,16 +17,18 @@ export const DataTableQuery = ({ prefix, }: DataTableQueryProps) => { return ( -
-
- {filters && filters.length > 0 && ( - - )} + (search || orderBy || filters || prefix) && ( +
+
+ {filters && filters.length > 0 && ( + + )} +
+
+ {search && } + {orderBy && } +
-
- {search && } - {orderBy && } -
-
+ ) ) } diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/taxes/type-cell/index.ts b/packages/admin-next/dashboard/src/components/table/table-cells/taxes/type-cell/index.ts new file mode 100644 index 0000000000..4db84006ba --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/taxes/type-cell/index.ts @@ -0,0 +1 @@ +export * from "./type-cell" diff --git a/packages/admin-next/dashboard/src/components/table/table-cells/taxes/type-cell/type-cell.tsx b/packages/admin-next/dashboard/src/components/table/table-cells/taxes/type-cell/type-cell.tsx new file mode 100644 index 0000000000..15b41543e0 --- /dev/null +++ b/packages/admin-next/dashboard/src/components/table/table-cells/taxes/type-cell/type-cell.tsx @@ -0,0 +1,33 @@ +import { Badge } from "@medusajs/ui" + +type CellProps = { + is_combinable: boolean +} + +type HeaderProps = { + text: string +} + +export const TypeCell = ({ is_combinable }: CellProps) => { + return ( +
+ + {is_combinable ? ( + + Combinable + + ) : ( + "" + )} + +
+ ) +} + +export const TypeHeader = ({ text }: HeaderProps) => { + return ( +
+ {text} +
+ ) +} diff --git a/packages/admin-next/dashboard/src/hooks/api/tax-rates.tsx b/packages/admin-next/dashboard/src/hooks/api/tax-rates.tsx new file mode 100644 index 0000000000..01983f2151 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/api/tax-rates.tsx @@ -0,0 +1,119 @@ +import { + AdminPostTaxRatesReq, + AdminPostTaxRatesTaxRateReq, +} from "@medusajs/medusa" +import { AdminTaxRateListResponse, AdminTaxRateResponse } from "@medusajs/types" +import { + QueryKey, + useMutation, + UseMutationOptions, + useQuery, + UseQueryOptions, +} from "@tanstack/react-query" +import { client } from "../../lib/client" +import { queryClient } from "../../lib/medusa" +import { queryKeysFactory } from "../../lib/query-key-factory" +import { TaxRateDeleteRes } from "../../types/api-responses" + +const TAX_RATES_QUERY_KEY = "tax_rates" as const +export const taxRatesQueryKeys = queryKeysFactory(TAX_RATES_QUERY_KEY) + +export const useTaxRate = ( + id: string, + query?: Record, + options?: Omit< + UseQueryOptions< + AdminTaxRateResponse, + Error, + AdminTaxRateResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryKey: taxRatesQueryKeys.detail(id), + queryFn: async () => client.taxes.retrieveTaxRate(id, query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useTaxRates = ( + query?: Record, + options?: Omit< + UseQueryOptions< + AdminTaxRateListResponse, + Error, + AdminTaxRateListResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => client.taxes.listTaxRates(query), + queryKey: taxRatesQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useUpdateTaxRate = ( + id: string, + options?: UseMutationOptions< + AdminTaxRateResponse, + Error, + AdminPostTaxRatesTaxRateReq + > +) => { + return useMutation({ + mutationFn: (payload) => client.taxes.updateTaxRate(id, payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: taxRatesQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: taxRatesQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useCreateTaxRate = ( + options?: UseMutationOptions< + AdminTaxRateResponse, + Error, + AdminPostTaxRatesReq + > +) => { + return useMutation({ + mutationFn: (payload) => client.taxes.createTaxRate(payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: taxRatesQueryKeys.lists() }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteTaxRate = ( + id: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.taxes.deleteTaxRate(id), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: taxRatesQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: taxRatesQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin-next/dashboard/src/hooks/api/tax-regions.tsx b/packages/admin-next/dashboard/src/hooks/api/tax-regions.tsx index b98230d3d7..6c516242fa 100644 --- a/packages/admin-next/dashboard/src/hooks/api/tax-regions.tsx +++ b/packages/admin-next/dashboard/src/hooks/api/tax-regions.tsx @@ -1,13 +1,22 @@ -import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query" -import { client } from "../../lib/client" -import { queryKeysFactory } from "../../lib/query-key-factory" +import { AdminCreateTaxRegion } from "@medusajs/medusa" import { - AdminTaxRegionResponse, AdminTaxRegionListResponse, + AdminTaxRegionResponse, } from "@medusajs/types" +import { + QueryKey, + UseMutationOptions, + UseQueryOptions, + useMutation, + useQuery, +} from "@tanstack/react-query" +import { client } from "../../lib/client" +import { queryClient } from "../../lib/medusa" +import { queryKeysFactory } from "../../lib/query-key-factory" +import { TaxRegionDeleteRes } from "../../types/api-responses" const TAX_REGIONS_QUERY_KEY = "tax_regions" as const -const taxRegionsQueryKeys = queryKeysFactory(TAX_REGIONS_QUERY_KEY) +export const taxRegionsQueryKeys = queryKeysFactory(TAX_REGIONS_QUERY_KEY) export const useTaxRegion = ( id: string, @@ -51,3 +60,38 @@ export const useTaxRegions = ( return { ...data, ...rest } } + +export const useCreateTaxRegion = ( + options?: UseMutationOptions< + AdminTaxRegionResponse, + Error, + AdminCreateTaxRegion + > +) => { + return useMutation({ + mutationFn: (payload) => client.taxes.createTaxRegion(payload), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: taxRegionsQueryKeys.all }) + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} + +export const useDeleteTaxRegion = ( + id: string, + options?: UseMutationOptions +) => { + return useMutation({ + mutationFn: () => client.taxes.deleteTaxRegion(id), + onSuccess: (data, variables, context) => { + queryClient.invalidateQueries({ queryKey: taxRegionsQueryKeys.lists() }) + queryClient.invalidateQueries({ + queryKey: taxRegionsQueryKeys.detail(id), + }) + + options?.onSuccess?.(data, variables, context) + }, + ...options, + }) +} diff --git a/packages/admin-next/dashboard/src/hooks/table/columns/use-tax-rates-table-columns.tsx b/packages/admin-next/dashboard/src/hooks/table/columns/use-tax-rates-table-columns.tsx new file mode 100644 index 0000000000..2e96600a36 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/columns/use-tax-rates-table-columns.tsx @@ -0,0 +1,55 @@ +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +import { TaxRateResponse } from "@medusajs/types" +import { + TextCell, + TextHeader, +} from "../../../components/table/table-cells/common/text-cell" + +import { + TypeCell, + TypeHeader, +} from "../../../components/table/table-cells/taxes/type-cell" + +const columnHelper = createColumnHelper() + +export const useTaxRateTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "name", + header: () => , + cell: ({ row }) => , + }), + columnHelper.display({ + id: "province", + header: () => , + cell: ({ row }) => ( + + ), + }), + columnHelper.display({ + id: "rate", + header: () => , + cell: ({ row }) => , + }), + columnHelper.display({ + id: "is_combinable", + header: () => , + cell: ({ row }) => ( + + ), + }), + columnHelper.display({ + id: "code", + header: () => , + cell: ({ row }) => , + }), + ], + [] + ) +} diff --git a/packages/admin-next/dashboard/src/hooks/table/filters/use-tax-rate-table-filters.tsx b/packages/admin-next/dashboard/src/hooks/table/filters/use-tax-rate-table-filters.tsx new file mode 100644 index 0000000000..2a21625940 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/filters/use-tax-rate-table-filters.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from "react-i18next" +import { Filter } from "../../../components/table/data-table" + +export const useTaxRateTableFilters = () => { + const { t } = useTranslation() + + const dateFilters: Filter[] = [ + { label: t("fields.createdAt"), key: "created_at" }, + { label: t("fields.updatedAt"), key: "updated_at" }, + ].map((f) => ({ + key: f.key, + label: f.label, + type: "date", + })) + + const filters = [...dateFilters] + + return filters +} diff --git a/packages/admin-next/dashboard/src/hooks/table/query/use-tax-rate-table-query.tsx b/packages/admin-next/dashboard/src/hooks/table/query/use-tax-rate-table-query.tsx new file mode 100644 index 0000000000..04fa145750 --- /dev/null +++ b/packages/admin-next/dashboard/src/hooks/table/query/use-tax-rate-table-query.tsx @@ -0,0 +1,33 @@ +import { AdminGetTaxRatesParams } from "@medusajs/medusa" +import { useQueryParams } from "../../use-query-params" + +type UseTaxRateTableQueryProps = { + prefix?: string + pageSize?: number +} + +export const useTaxRateTableQuery = ({ + prefix, + pageSize = 20, +}: UseTaxRateTableQueryProps) => { + const queryObject = useQueryParams( + ["offset", "q", "order", "created_at", "updated_at"], + prefix + ) + + const { offset, q, order, created_at, updated_at } = queryObject + + const searchParams: AdminGetTaxRatesParams = { + limit: pageSize, + offset: offset ? Number(offset) : 0, + order, + created_at: created_at ? JSON.parse(created_at) : undefined, + updated_at: updated_at ? JSON.parse(updated_at) : undefined, + q, + } + + return { + searchParams, + raw: queryObject, + } +} diff --git a/packages/admin-next/dashboard/src/lib/client/taxes.ts b/packages/admin-next/dashboard/src/lib/client/taxes.ts index 4edb7f0aac..6eb9d4d7c5 100644 --- a/packages/admin-next/dashboard/src/lib/client/taxes.ts +++ b/packages/admin-next/dashboard/src/lib/client/taxes.ts @@ -1,8 +1,14 @@ import { + AdminCreateTaxRegion, + AdminPostTaxRatesTaxRateReq, +} from "@medusajs/medusa" +import { + AdminTaxRateResponse, AdminTaxRegionListResponse, AdminTaxRegionResponse, } from "@medusajs/types" -import { getRequest } from "./common" +import { TaxRateDeleteRes, TaxRegionDeleteRes } from "../../types/api-responses" +import { deleteRequest, getRequest, postRequest } from "./common" async function retrieveTaxRegion(id: string, query?: Record) { return getRequest(`/admin/tax-regions/${id}`, query) @@ -12,7 +18,42 @@ async function listTaxRegions(query?: Record) { return getRequest(`/admin/tax-regions`, query) } +async function createTaxRegion(payload: AdminCreateTaxRegion) { + return postRequest(`/admin/tax-regions`, payload) +} + +async function deleteTaxRegion(id: string) { + return deleteRequest(`/admin/tax-regions/${id}`) +} + +async function retrieveTaxRate(id: string, query?: Record) { + return getRequest(`/admin/tax-rates/${id}`, query) +} + +async function listTaxRates(query?: Record) { + return getRequest(`/admin/tax-rates`, query) +} + +async function updateTaxRate(id: string, payload: AdminPostTaxRatesTaxRateReq) { + return postRequest(`/admin/tax-rates/${id}`, payload) +} + +async function createTaxRate(payload: AdminCreateTaxRate) { + return postRequest(`/admin/tax-rates`, payload) +} + +async function deleteTaxRate(id: string) { + return deleteRequest(`/admin/tax-rates/${id}`) +} + export const taxes = { retrieveTaxRegion, listTaxRegions, + retrieveTaxRate, + listTaxRates, + updateTaxRate, + createTaxRegion, + deleteTaxRegion, + createTaxRate, + deleteTaxRate, } diff --git a/packages/admin-next/dashboard/src/lib/countries.ts b/packages/admin-next/dashboard/src/lib/countries.ts index 62ce8cea73..bf1fa276d9 100644 --- a/packages/admin-next/dashboard/src/lib/countries.ts +++ b/packages/admin-next/dashboard/src/lib/countries.ts @@ -1,6 +1,16 @@ /** This file is auto-generated. Do not modify it manually. */ import type { RegionCountryDTO } from "@medusajs/types" +export function getCountryByIso2( + iso2: string | null +): Omit | undefined { + if (!iso2) { + return + } + + return countries.find((c) => c.iso_2 === iso2) +} + export const countries: Omit[] = [ { iso_2: "af", 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 9505e090bc..0c4b2bf749 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/v2.tsx @@ -9,6 +9,8 @@ import { AdminApiKeyResponse, AdminCustomerGroupResponse, AdminProductCategoryResponse, + AdminTaxRateResponse, + AdminTaxRegionResponse, SalesChannelDTO, UserDTO, } from "@medusajs/types" @@ -757,7 +759,56 @@ export const v2Routes: RouteObject[] = [ { path: "", lazy: () => import("../../v2-routes/taxes/tax-region-list"), - children: [], + children: [ + { + path: "create", + lazy: () => + import("../../v2-routes/taxes/tax-region-create"), + children: [], + }, + ], + }, + { + path: ":id", + lazy: () => import("../../v2-routes/taxes/tax-region-detail"), + handle: { + crumb: (data: AdminTaxRegionResponse) => { + return data.tax_region.country_code + }, + }, + children: [ + { + path: "create-default", + lazy: () => + import("../../v2-routes/taxes/tax-province-create"), + children: [], + }, + { + path: "create-override", + lazy: () => import("../../v2-routes/taxes/tax-rate-create"), + children: [], + }, + { + path: "tax-rates", + children: [ + { + path: ":taxRateId", + children: [ + { + path: "edit", + lazy: () => + import("../../v2-routes/taxes/tax-rate-edit"), + handle: { + crumb: (data: AdminTaxRateResponse) => { + return data.tax_rate.code + }, + }, + }, + ], + }, + ], + }, + ], }, ], }, diff --git a/packages/admin-next/dashboard/src/types/api-responses.ts b/packages/admin-next/dashboard/src/types/api-responses.ts index 6bdfaa7ddb..ffbf536684 100644 --- a/packages/admin-next/dashboard/src/types/api-responses.ts +++ b/packages/admin-next/dashboard/src/types/api-responses.ts @@ -142,6 +142,10 @@ export type ProductCollectionListRes = { } & ListRes export type ProductCollectionDeleteRes = DeleteRes +// Taxes +export type TaxRegionDeleteRes = DeleteRes +export type TaxRateDeleteRes = DeleteRes + // Inventory Items export type InventoryItemRes = { inventory_item: InventoryNext.InventoryItemDTO & { diff --git a/packages/admin-next/dashboard/src/v2-routes/regions/region-list/components/region-list-table/region-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/regions/region-list/components/region-list-table/region-list-table.tsx index b07fef8289..d037b1fc2b 100644 --- a/packages/admin-next/dashboard/src/v2-routes/regions/region-list/components/region-list-table/region-list-table.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/regions/region-list/components/region-list-table/region-list-table.tsx @@ -1,18 +1,18 @@ import { PencilSquare, Trash } from "@medusajs/icons" +import { RegionDTO } from "@medusajs/types" import { Button, Container, Heading, usePrompt } from "@medusajs/ui" import { createColumnHelper } from "@tanstack/react-table" import { useMemo } from "react" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" -import { RegionDTO } from "@medusajs/types" import { ActionMenu } from "../../../../../components/common/action-menu" import { DataTable } from "../../../../../components/table/data-table" +import { useDeleteRegion, useRegions } from "../../../../../hooks/api/regions" import { useRegionTableColumns } from "../../../../../hooks/table/columns/use-region-table-columns" import { useRegionTableFilters } from "../../../../../hooks/table/filters/use-region-table-filters" import { useRegionTableQuery } from "../../../../../hooks/table/query/use-region-table-query" import { useDataTable } from "../../../../../hooks/use-data-table" -import { useDeleteRegion, useRegions } from "../../../../../hooks/api/regions" const PAGE_SIZE = 20 @@ -56,6 +56,7 @@ export const RegionListTable = () => {
+ void +} + +const initRowState = (selected: ConditionsOption[] = []): RowSelectionState => { + return selected.reduce((acc, { value }) => { + acc[value] = true + return acc + }, {} as RowSelectionState) +} + +const ConditionsFooter = ({ onSave }: { onSave: () => void }) => { + const { t } = useTranslation() + + return ( +
+ + + + + +
+ ) +} + +const ProductConditionsTable = ({ selected = [], onSave }: ConditionsProps) => { + const [rowSelection, setRowSelection] = useState( + initRowState(selected) + ) + + const [intermediate, setIntermediate] = useState(selected) + + const { searchParams, raw } = useProductTableQuery({ + pageSize: PAGE_SIZE, + prefix: PRODUCT_PREFIX, + }) + const { products, count, isLoading, isError, error } = useProducts( + { + ...searchParams, + }, + { + keepPreviousData: true, + } + ) + + const updater: OnChangeFn = (fn) => { + const newState: RowSelectionState = + typeof fn === "function" ? fn(rowSelection) : fn + + const added = Object.keys(newState).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (added.length) { + const addedProducts = (products?.filter((p) => added.includes(p.id!)) ?? + []) as Product[] + + if (addedProducts.length > 0) { + const newConditions = addedProducts.map((p) => ({ + label: p.title, + value: p.id!, + })) + + setIntermediate((prev) => { + const filteredPrev = prev.filter((p) => p.value in newState) + return Array.from(new Set([...filteredPrev, ...newConditions])) + }) + } + + setRowSelection(newState) + } + + const removed = Object.keys(rowSelection).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (removed.length) { + setIntermediate((prev) => { + return prev.filter((p) => !removed.includes(p.value)) + }) + + setRowSelection(newState) + } + } + + const handleSave = () => { + onSave(intermediate) + } + + const columns = useProductConditionsTableColumns() + const filters = useProductTableFilters() + + const { table } = useDataTable({ + data: products ?? [], + columns: columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + enableRowSelection: true, + rowSelection: { + state: rowSelection, + updater, + }, + prefix: PRODUCT_PREFIX, + }) + + if (isError) { + throw error + } + + return ( +
+ + +
+ ) +} + +const CustomerGroupConditionsTable = ({ + selected = [], + onSave, +}: ConditionsProps) => { + const [rowSelection, setRowSelection] = useState( + initRowState(selected) + ) + + const [intermediate, setIntermediate] = useState(selected) + + const { searchParams, raw } = useCustomerGroupTableQuery({ + pageSize: PAGE_SIZE, + prefix: PRODUCT_PREFIX, + }) + const { customer_groups, count, isLoading, isError, error } = + useCustomerGroups( + { + ...searchParams, + }, + { + keepPreviousData: true, + } + ) + + const updater: OnChangeFn = (fn) => { + const newState: RowSelectionState = + typeof fn === "function" ? fn(rowSelection) : fn + + const added = Object.keys(newState).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (added.length) { + const addedGroups = + customer_groups?.filter((p) => added.includes(p.id!)) ?? [] + + if (addedGroups.length > 0) { + const newConditions = addedGroups.map((p) => ({ + label: p.name, + value: p.id!, + })) + + setIntermediate((prev) => { + const filteredPrev = prev.filter((p) => p.value in newState) + return Array.from(new Set([...filteredPrev, ...newConditions])) + }) + } + + setRowSelection(newState) + } + + const removed = Object.keys(rowSelection).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (removed.length) { + setIntermediate((prev) => { + return prev.filter((p) => !removed.includes(p.value)) + }) + + setRowSelection(newState) + } + } + + const handleSave = () => { + onSave(intermediate) + } + + const columns = useCustomerGroupConditionsTableColumns() + const filters = useCustomerGroupConditionsTableFilters() + + const { table } = useDataTable({ + data: (customer_groups ?? []) as CustomerGroup[], + columns: columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + enableRowSelection: true, + rowSelection: { + state: rowSelection, + updater, + }, + prefix: CUSTOMER_GROUP_PREFIX, + }) + + if (isError) { + throw error + } + + return ( +
+ + +
+ ) +} + +const ProductTypeConditionsTable = ({ + onSave, + selected = [], +}: ConditionsProps) => { + const [rowSelection, setRowSelection] = useState( + initRowState(selected) + ) + const [intermediate, setIntermediate] = useState(selected) + + const { searchParams, raw } = useProductTypeConditionsTableQuery({ + pageSize: PAGE_SIZE, + prefix: PRODUCT_TYPE_PREFIX, + }) + const { product_types, count, isLoading, isError, error } = useProductTypes( + { + ...searchParams, + }, + { + keepPreviousData: true, + } + ) + + const updater: OnChangeFn = (fn) => { + const newState: RowSelectionState = + typeof fn === "function" ? fn(rowSelection) : fn + + const added = Object.keys(newState).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (added.length) { + const addedTypes = (product_types?.filter((p) => added.includes(p.id!)) ?? + []) as ProductType[] + + if (addedTypes.length > 0) { + const newConditions = addedTypes.map((p) => ({ + label: p.value, + value: p.id!, + })) + + setIntermediate((prev) => { + const filteredPrev = prev.filter((p) => p.value in newState) + return Array.from(new Set([...filteredPrev, ...newConditions])) + }) + } + + setRowSelection(newState) + } + const removed = Object.keys(rowSelection).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (removed.length) { + setIntermediate((prev) => { + return prev.filter((p) => !removed.includes(p.value)) + }) + + setRowSelection(newState) + } + } + + const handleSave = () => { + onSave(intermediate) + } + + const columns = useProductTypeConditionsTableColumns() + const filters = useProductTypeConditionsTableFilters() + + const { table } = useDataTable({ + data: product_types ?? [], + columns: columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + enableRowSelection: true, + rowSelection: { + state: rowSelection, + updater, + }, + prefix: PRODUCT_TYPE_PREFIX, + }) + + if (isError) { + throw error + } + + return ( +
+ + +
+ ) +} + +const ProductCollectionConditionsTable = ({ + onSave, + selected = [], +}: ConditionsProps) => { + const [rowSelection, setRowSelection] = useState( + initRowState(selected) + ) + const [intermediate, setIntermediate] = useState(selected) + + const { searchParams, raw } = useProductCollectionConditionsTableQuery({ + pageSize: PAGE_SIZE, + prefix: PRODUCT_COLLECTION_PREFIX, + }) + const { collections, count, isPending, isError, error } = useCollections( + { + ...searchParams, + }, + { + keepPreviousData: true, + } + ) + + const updater: OnChangeFn = (fn) => { + const newState: RowSelectionState = + typeof fn === "function" ? fn(rowSelection) : fn + + const added = Object.keys(newState).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (added.length) { + const addedCollections = (collections?.filter((p) => + added.includes(p.id!) + ) ?? []) as ProductCollectionDTO[] + + if (addedCollections.length > 0) { + const newConditions = addedCollections.map((p) => ({ + label: p.title, + value: p.id!, + })) + + setIntermediate((prev) => { + const filteredPrev = prev.filter((p) => p.value in newState) + return Array.from(new Set([...filteredPrev, ...newConditions])) + }) + } + + setRowSelection(newState) + } + + const removed = Object.keys(rowSelection).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (removed.length) { + setIntermediate((prev) => { + return prev.filter((p) => !removed.includes(p.value)) + }) + + setRowSelection(newState) + } + } + + const handleSave = () => { + onSave(intermediate) + } + + const columns = useProductCollectionConditionsTableColumns() + const filters = useProductCollectionConditionsTableFilters() + + const { table } = useDataTable({ + data: collections ?? [], + columns: columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + enableRowSelection: true, + rowSelection: { + state: rowSelection, + updater, + }, + prefix: PRODUCT_COLLECTION_PREFIX, + }) + + if (isError) { + throw error + } + + return ( +
+ + +
+ ) +} + +const ProductTagConditionsTable = ({ + onSave, + selected = [], +}: ConditionsProps) => { + const [rowSelection, setRowSelection] = useState( + initRowState(selected) + ) + const [intermediate, setIntermediate] = useState(selected) + + const { searchParams, raw } = useProductTagConditionsTableQuery({ + pageSize: PAGE_SIZE, + prefix: PRODUCT_TAG_PREFIX, + }) + + // TODO: replace this with useProductTags when its available + const { product_tags, count, isLoading, isError, error } = + useAdminProductTags( + { + ...searchParams, + }, + { + keepPreviousData: true, + } + ) + + const updater: OnChangeFn = (fn) => { + const newState: RowSelectionState = + typeof fn === "function" ? fn(rowSelection) : fn + + const added = Object.keys(newState).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (added.length) { + const addedTags = (product_tags?.filter((p) => added.includes(p.id!)) ?? + []) as ProductTag[] + + if (addedTags.length > 0) { + const newConditions = addedTags.map((p) => ({ + label: p.value, + value: p.id!, + })) + + setIntermediate((prev) => { + const filteredPrev = prev.filter((p) => p.value in newState) + return Array.from(new Set([...filteredPrev, ...newConditions])) + }) + } + + setRowSelection(newState) + } + + const removed = Object.keys(rowSelection).filter( + (k) => newState[k] !== rowSelection[k] + ) + + if (removed.length) { + setIntermediate((prev) => { + return prev.filter((p) => !removed.includes(p.value)) + }) + + setRowSelection(newState) + } + } + + const handleSave = () => { + onSave(intermediate) + } + + const columns = useProductTagConditionsTableColumns() + const filters = useProductTagConditionsTableFilters() + + const { table } = useDataTable({ + data: product_tags ?? [], + columns: columns, + count, + enablePagination: true, + getRowId: (row) => row.id, + pageSize: PAGE_SIZE, + enableRowSelection: true, + rowSelection: { + state: rowSelection, + updater, + }, + prefix: PRODUCT_TAG_PREFIX, + }) + + if (isError) { + throw error + } + + return ( +
+ + +
+ ) +} + +type ConditionsTableProps = { + product: ConditionsProps + productType: ConditionsProps + productTag: ConditionsProps + productCollection: ConditionsProps + customerGroup: ConditionsProps + selected: ConditionEntities | null +} + +export const ConditionsDrawer = ({ + product, + productType, + customerGroup, + productCollection, + productTag, + selected, +}: ConditionsTableProps) => { + switch (selected) { + case ConditionEntities.PRODUCT: + return + case ConditionEntities.PRODUCT_TYPE: + return + case ConditionEntities.PRODUCT_COLLECTION: + return + case ConditionEntities.PRODUCT_TAG: + return + case ConditionEntities.CUSTOMER_GROUP: + return + default: + return null + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/components/conditions-drawer/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/common/components/conditions-drawer/index.ts new file mode 100644 index 0000000000..b65a27e54d --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/components/conditions-drawer/index.ts @@ -0,0 +1 @@ +export * from "./conditions-drawer" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/components/tax-region-create-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/common/components/tax-region-create-form/index.ts new file mode 100644 index 0000000000..e8bb24daae --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/components/tax-region-create-form/index.ts @@ -0,0 +1 @@ +export * from "./tax-region-create-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/components/tax-region-create-form/tax-region-create-form.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/components/tax-region-create-form/tax-region-create-form.tsx new file mode 100644 index 0000000000..6de3816e89 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/components/tax-region-create-form/tax-region-create-form.tsx @@ -0,0 +1,250 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { Button, Heading, Input, Select, Switch, Text } from "@medusajs/ui" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" + +import { TaxRegionResponse } from "@medusajs/types" +import { Form } from "../../../../../components/common/form" +import { PercentageInput } from "../../../../../components/common/percentage-input" +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { useCreateTaxRegion } from "../../../../../hooks/api/tax-regions" +import { countries } from "../../../../../lib/countries" + +export const TaxRegionCreateForm = ({ + taxRegion, + formSchema, +}: { + taxRegion?: TaxRegionResponse + formSchema: zod.ZodObject<{ + province_code: any + country_code: any + parent_id: any + name: any + code: any + rate: any + is_combinable: any + }> +}) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + country_code: taxRegion?.country_code || undefined, + parent_id: taxRegion?.id || undefined, + }, + resolver: zodResolver(formSchema), + }) + + const { mutateAsync, isPending } = useCreateTaxRegion() + + const handleSubmit = form.handleSubmit(async (data) => { + await mutateAsync( + { + parent_id: taxRegion?.id, + province_code: data.province_code, + country_code: data.country_code, + default_tax_rate: { + name: data.name, + code: data.code, + rate: data.rate, + }, + }, + { + onSuccess: () => { + taxRegion?.id + ? handleSuccess(`/settings/taxes/${taxRegion.id}`) + : handleSuccess(`/settings/taxes`) + }, + } + ) + }) + + return ( + +
+ +
+ + + + + +
+
+ + +
+
+ + {taxRegion + ? t("taxRegions.create-child.title") + : t("taxRegions.create.title")} + + + + {taxRegion + ? t("taxRegions.create-child.description") + : t("taxRegions.create.description")} + +
+ + {!taxRegion && ( + { + return ( + + {t("fields.country")} + + + + + + + + ) + }} + /> + )} + + {taxRegion && ( + { + return ( + + {t("fields.province")} + + + + + + ) + }} + /> + )} + + { + return ( + + Tax Rate Name + + + + + + ) + }} + /> + + { + return ( + + {t("fields.rate")} + + + { + if (e.target.value) { + field.onChange(parseInt(e.target.value)) + } + }} + /> + + + + {t("taxRegions.fields.rate.hint")} + + + ) + }} + /> + + { + return ( + + {t("fields.code")} + + + + + + ) + }} + /> + + {!taxRegion?.parent_id && ( + { + return ( + + + {t("taxRates.fields.isCombinable")} + + + + + + + + {t("taxRegions.fields.is_combinable.hint")} + + + ) + }} + /> + )} +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/constants.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/common/constants.ts new file mode 100644 index 0000000000..2541fdb756 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/constants.ts @@ -0,0 +1,11 @@ +export enum ConditionEntities { + PRODUCT = "products", + PRODUCT_TYPE = "product_types", + PRODUCT_COLLECTION = "product_collections", + PRODUCT_TAG = "product_tags", + CUSTOMER_GROUP = "customer_groups", +} + +export enum Operators { + IN = "in", +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-customer-group-conditions-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-customer-group-conditions-table-columns.tsx new file mode 100644 index 0000000000..5ed653dad6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-customer-group-conditions-table-columns.tsx @@ -0,0 +1,46 @@ +import { CustomerGroup } from "@medusajs/medusa" +import { Checkbox } from "@medusajs/ui" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useCustomerGroupTableColumns } from "../../../../../hooks/table/columns/use-customer-group-table-columns" + +const columnHelper = createColumnHelper() + +export const useCustomerGroupConditionsTableColumns = () => { + const base = useCustomerGroupTableColumns() + + 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/v2-routes/taxes/common/hooks/columns/use-product-collection-conditions-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-collection-conditions-table-columns.tsx new file mode 100644 index 0000000000..b61ee5e7f6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-collection-conditions-table-columns.tsx @@ -0,0 +1,60 @@ +import { ProductCollectionDTO } from "@medusajs/types" +import { Checkbox } from "@medusajs/ui" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +const columnHelper = createColumnHelper() + +export const useProductCollectionConditionsTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + columnHelper.accessor("title", { + header: t("fields.title"), + }), + columnHelper.accessor("handle", { + header: t("fields.handle"), + cell: ({ getValue }) => `/${getValue()}`, + }), + columnHelper.accessor("products", { + header: t("fields.products"), + cell: ({ getValue }) => { + const count = getValue()?.length + + return {count || "-"} + }, + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-conditions-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-conditions-table-columns.tsx new file mode 100644 index 0000000000..9fc6850729 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-conditions-table-columns.tsx @@ -0,0 +1,46 @@ +import { Product } from "@medusajs/medusa" +import { Checkbox } from "@medusajs/ui" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns" + +const columnHelper = createColumnHelper() + +export const useProductConditionsTableColumns = () => { + 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/v2-routes/taxes/common/hooks/columns/use-product-tag-conditions-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-tag-conditions-table-columns.tsx new file mode 100644 index 0000000000..540c8d1354 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-tag-conditions-table-columns.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from "react-i18next" +import { useMemo } from "react" +import { createColumnHelper } from "@tanstack/react-table" +import { ProductTag } from "@medusajs/medusa" +import { Checkbox } from "@medusajs/ui" + +const columnHelper = createColumnHelper() + +export const useProductTagConditionsTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + columnHelper.accessor("value", { + header: t("fields.value"), + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-type-conditions-table-columns.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-type-conditions-table-columns.tsx new file mode 100644 index 0000000000..9066f5b02f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/columns/use-product-type-conditions-table-columns.tsx @@ -0,0 +1,49 @@ +import { ProductType } from "@medusajs/medusa" +import { Checkbox } from "@medusajs/ui" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +const columnHelper = createColumnHelper() + +export const useProductTypeConditionsTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + columnHelper.accessor("value", { + header: t("fields.value"), + cell: ({ getValue }) => getValue(), + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-customer-group-conditions-table-filters.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-customer-group-conditions-table-filters.tsx new file mode 100644 index 0000000000..50859c8e24 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-customer-group-conditions-table-filters.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next" +import { Filter } from "../../../../../components/table/data-table" + +export const useCustomerGroupConditionsTableFilters = () => { + const { t } = useTranslation() + + const filters: Filter[] = [ + { label: t("fields.createdAt"), key: "created_at" }, + { label: t("fields.updatedAt"), key: "updated_at" }, + ].map((f) => ({ + key: f.key, + label: f.label, + type: "date", + })) + + return filters +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-product-collection-conditions-table-filters.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-product-collection-conditions-table-filters.tsx new file mode 100644 index 0000000000..45de4cc975 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-product-collection-conditions-table-filters.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next" +import { Filter } from "../../../../../components/table/data-table" + +export const useProductCollectionConditionsTableFilters = () => { + const { t } = useTranslation() + + const filters: Filter[] = [ + { label: t("fields.createdAt"), key: "created_at" }, + { label: t("fields.updatedAt"), key: "updated_at" }, + ].map((f) => ({ + key: f.key, + label: f.label, + type: "date", + })) + + return filters +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-product-tag-conditions-table-filters.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-product-tag-conditions-table-filters.tsx new file mode 100644 index 0000000000..b79dad59a0 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-product-tag-conditions-table-filters.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next" +import { Filter } from "../../../../../components/table/data-table" + +export const useProductTagConditionsTableFilters = () => { + const { t } = useTranslation() + + const filters: Filter[] = [ + { label: t("fields.createdAt"), key: "created_at" }, + { label: t("fields.updatedAt"), key: "updated_at" }, + ].map((f) => ({ + key: f.key, + label: f.label, + type: "date", + })) + + return filters +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-product-type-conditions-table-filters.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-product-type-conditions-table-filters.tsx new file mode 100644 index 0000000000..7c9a74018c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/filters/use-product-type-conditions-table-filters.tsx @@ -0,0 +1,17 @@ +import { useTranslation } from "react-i18next" +import { Filter } from "../../../../../components/table/data-table" + +export const useProductTypeConditionsTableFilters = () => { + const { t } = useTranslation() + + const filters: Filter[] = [ + { label: t("fields.createdAt"), key: "created_at" }, + { label: t("fields.updatedAt"), key: "updated_at" }, + ].map((f) => ({ + key: f.key, + label: f.label, + type: "date", + })) + + return filters +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/query/use-product-collection-conditions-table-query.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/query/use-product-collection-conditions-table-query.tsx new file mode 100644 index 0000000000..159ab0a8bd --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/query/use-product-collection-conditions-table-query.tsx @@ -0,0 +1,42 @@ +import { AdminGetCollectionsParams } from "@medusajs/medusa" + +import { useQueryParams } from "../../../../../hooks/use-query-params" + +export const useProductCollectionConditionsTableQuery = ({ + pageSize = 50, + prefix, +}: { + pageSize?: number + prefix: string +}) => { + const raw = useQueryParams( + [ + "offset", + "q", + "order", + "title", + "handle", + "discount_condition_id", + "created_at", + "updated_at", + ], + prefix + ) + + const searchParams: AdminGetCollectionsParams = { + limit: pageSize, + offset: raw.offset ? Number(raw.offset) : 0, + q: raw.q, + title: raw.title, + handle: raw.handle, + discount_condition_id: raw.discount_condition_id, + created_at: raw.created_at ? JSON.parse(raw.created_at) : undefined, + updated_at: raw.updated_at ? JSON.parse(raw.updated_at) : undefined, + order: raw.order, + } + + return { + searchParams, + raw, + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/query/use-product-tag-conditions-table-query.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/query/use-product-tag-conditions-table-query.tsx new file mode 100644 index 0000000000..717cab17f6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/query/use-product-tag-conditions-table-query.tsx @@ -0,0 +1,39 @@ +import { AdminGetProductTagsParams } from "@medusajs/medusa" + +import { useQueryParams } from "../../../../../hooks/use-query-params" + +export const useProductTagConditionsTableQuery = ({ + pageSize = 50, + prefix, +}: { + pageSize?: number + prefix: string +}) => { + const raw = useQueryParams( + [ + "offset", + "q", + "order", + "value", + "discount_condition_id", + "created_at", + "updated_at", + ], + prefix + ) + + const searchParams: AdminGetProductTagsParams = { + limit: pageSize, + offset: raw.offset ? Number(raw.offset) : 0, + q: raw.q, + discount_condition_id: raw.discount_condition_id, + created_at: raw.created_at ? JSON.parse(raw.created_at) : undefined, + updated_at: raw.updated_at ? JSON.parse(raw.updated_at) : undefined, + order: raw.order, + } + + return { + searchParams, + raw, + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/query/use-product-type-conditions-table-query.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/query/use-product-type-conditions-table-query.tsx new file mode 100644 index 0000000000..737d00d143 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/hooks/query/use-product-type-conditions-table-query.tsx @@ -0,0 +1,29 @@ +import { AdminGetProductTypesParams } from "@medusajs/medusa" +import { useQueryParams } from "../../../../../hooks/use-query-params" + +export const useProductTypeConditionsTableQuery = ({ + pageSize = 50, + prefix, +}: { + pageSize?: number + prefix: string +}) => { + const raw = useQueryParams( + ["offset", "q", "order", "created_at", "updated_at"], + prefix + ) + + const searchParams: AdminGetProductTypesParams = { + limit: pageSize, + offset: raw.offset ? Number(raw.offset) : 0, + q: raw.q, + created_at: raw.created_at ? JSON.parse(raw.created_at) : undefined, + updated_at: raw.updated_at ? JSON.parse(raw.updated_at) : undefined, + order: raw.order, + } + + return { + searchParams, + raw, + } +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/common/types.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/common/types.ts new file mode 100644 index 0000000000..59b303c420 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/common/types.ts @@ -0,0 +1,12 @@ +import { ConditionEntities } from "./constants" + +export type ConditionsOption = { + value: string + label: string +} + +export type ConditionsState = { + [K in ConditionEntities]: boolean +} + +export type ConditionEntitiesValues = `${ConditionEntities}` diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-province-create/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-province-create/index.ts new file mode 100644 index 0000000000..ace2775212 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-province-create/index.ts @@ -0,0 +1,3 @@ +export * from "./tax-province-create" + +export { TaxProvinceCreate as Component } from "./tax-province-create" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-province-create/tax-province-create.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-province-create/tax-province-create.tsx new file mode 100644 index 0000000000..031809dda8 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-province-create/tax-province-create.tsx @@ -0,0 +1,36 @@ +import { useParams } from "react-router-dom" +import * as zod from "zod" +import { RouteFocusModal } from "../../../components/route-modal" +import { useTaxRegion } from "../../../hooks/api/tax-regions" +import { TaxRegionCreateForm } from "../common/components/tax-region-create-form" + +const CreateTaxProvinceForm = zod.object({ + province_code: zod.string(), + country_code: zod.string(), + parent_id: zod.string(), + name: zod.string(), + code: zod.string().optional(), + rate: zod.number(), + is_combinable: zod.boolean().default(false), +}) + +export const TaxProvinceCreate = () => { + const { id } = useParams() + + const { tax_region: taxRegion } = useTaxRegion( + id!, + {}, + { + enabled: !!id, + } + ) + + return ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/condition/condition.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/condition/condition.tsx new file mode 100644 index 0000000000..d3a836601e --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/condition/condition.tsx @@ -0,0 +1,53 @@ +import { Button, Text } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { ListSummary } from "../../../../../components/common/list-summary" +import { ConditionEntitiesValues } from "../../../common/types" + +const N = 2 + +type ConditionProps = { + labels: string[] + type: ConditionEntitiesValues + onClick: () => void +} + +export function Condition({ labels, type, onClick }: ConditionProps) { + const { t } = useTranslation() + const isInButtonDisabled = !!labels.length + + return ( +
+
+ + {t("taxRates.fields.appliesTo")} {t(`taxRates.fields.${type}`)} + + +
+ +
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/condition/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/condition/index.ts new file mode 100644 index 0000000000..1e9884b3b3 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/condition/index.ts @@ -0,0 +1 @@ +export * from "./condition" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/index.ts new file mode 100644 index 0000000000..3cf124f9d6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/index.ts @@ -0,0 +1,2 @@ +export * from "./condition" +export * from "./tax-rate-create-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/tax-rate-create-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/tax-rate-create-form/index.ts new file mode 100644 index 0000000000..69b098c24a --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/tax-rate-create-form/index.ts @@ -0,0 +1 @@ +export * from "./tax-rate-create-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/tax-rate-create-form/tax-rate-create-form.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/tax-rate-create-form/tax-rate-create-form.tsx new file mode 100644 index 0000000000..5704bdb1b9 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/components/tax-rate-create-form/tax-rate-create-form.tsx @@ -0,0 +1,480 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { + Button, + clx, + DropdownMenu, + Heading, + Input, + Select, + Switch, + Text, +} from "@medusajs/ui" +import { useForm, useWatch } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" + +import { TaxRegionResponse } from "@medusajs/types" +import { useState } from "react" +import { useSearchParams } from "react-router-dom" +import { Form } from "../../../../../components/common/form" +import { PercentageInput } from "../../../../../components/common/percentage-input" +import { SplitView } from "../../../../../components/layout/split-view" +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { useCreateTaxRate } from "../../../../../hooks/api/tax-rates" +import { useTaxRegions } from "../../../../../hooks/api/tax-regions" +import { ConditionsDrawer } from "../../../common/components/conditions-drawer" +import { ConditionEntities } from "../../../common/constants" +import { + ConditionEntitiesValues, + ConditionsOption, +} from "../../../common/types" +import { Condition } from "../condition" + +const SelectedConditionTypesSchema = zod.object({ + [ConditionEntities.PRODUCT]: zod.boolean(), + [ConditionEntities.PRODUCT_COLLECTION]: zod.boolean(), + [ConditionEntities.PRODUCT_TAG]: zod.boolean(), + [ConditionEntities.PRODUCT_TYPE]: zod.boolean(), + [ConditionEntities.CUSTOMER_GROUP]: zod.boolean(), +}) + +const ConditionSchema = zod.array( + zod.object({ + label: zod.string(), + value: zod.string(), + }) +) + +const CreateTaxRateSchema = zod.object({ + tax_region_id: zod.string(), + name: zod.string(), + code: zod.string(), + rate: zod.number(), + is_combinable: zod.boolean().default(false), + selected_condition_types: SelectedConditionTypesSchema, + products: ConditionSchema, + product_types: ConditionSchema, + product_collections: ConditionSchema, + product_tags: ConditionSchema, + customer_groups: ConditionSchema, +}) + +export const TaxRateCreateForm = ({ + taxRegion, +}: { + taxRegion: TaxRegionResponse +}) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const form = useForm>({ + defaultValues: { + selected_condition_types: { + [ConditionEntities.PRODUCT]: true, + [ConditionEntities.PRODUCT_TYPE]: false, + [ConditionEntities.PRODUCT_COLLECTION]: false, + [ConditionEntities.PRODUCT_TAG]: false, + [ConditionEntities.CUSTOMER_GROUP]: false, + }, + products: [], + product_types: [], + product_collections: [], + product_tags: [], + customer_groups: [], + }, + resolver: zodResolver(CreateTaxRateSchema), + }) + + const { tax_regions: taxRegions } = useTaxRegions({ + parent_id: taxRegion.id, + province_code: { $ne: "null" }, + }) + + const { mutateAsync, isPending } = useCreateTaxRate() + + const handleSubmit = form.handleSubmit(async (data) => { + await mutateAsync( + { + name: data.name, + code: data.code, + rate: data.rate, + is_combinable: data.is_combinable, + tax_region_id: data.tax_region_id, + rules: + data.products?.map((product) => ({ + reference: "product", + reference_id: product.value, + })) || [], + }, + { + onSuccess: () => handleSuccess(`/settings/taxes/${taxRegion.id}`), + } + ) + }) + + const [open, setOpen] = useState(false) + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const [conditionType, setConditionType] = useState( + null + ) + const selectedConditionTypes = useWatch({ + name: "selected_condition_types", + control: form.control, + }) + + const selectedProducts = useWatch({ + control: form.control, + name: "products", + }) + + const selectedProductCollections = useWatch({ + control: form.control, + name: "product_collections", + }) + + const selectedProductTypes = useWatch({ + control: form.control, + name: "product_types", + }) + + const selectedProductTags = useWatch({ + control: form.control, + name: "product_tags", + }) + + const selectedCustomerGroups = useWatch({ + control: form.control, + name: "customer_groups", + }) + + const handleSaveConditions = (type: ConditionEntitiesValues) => { + return (options: ConditionsOption[]) => { + form.setValue(type, options, { + shouldDirty: true, + shouldTouch: true, + }) + + setOpen(false) + } + } + + const selectedTypes = Object.keys(selectedConditionTypes || {}) + .filter( + (k) => selectedConditionTypes[k as keyof typeof selectedConditionTypes] + ) + .sort() as ConditionEntities[] + + const toggleSelectedConditionTypes = (type: ConditionEntities) => { + const state = { ...form.getValues().selected_condition_types } + + if (state[type]) { + delete state[type] + } else { + state[type] = true + } + + form.setValue("selected_condition_types", state, { + shouldDirty: true, + shouldTouch: true, + }) + } + + const clearAllSelectedConditions = () => { + form.setValue( + "selected_condition_types", + { + [ConditionEntities.PRODUCT]: false, + [ConditionEntities.PRODUCT_TYPE]: false, + [ConditionEntities.PRODUCT_COLLECTION]: false, + [ConditionEntities.PRODUCT_TAG]: false, + [ConditionEntities.CUSTOMER_GROUP]: false, + }, + { + shouldDirty: true, + shouldTouch: true, + } + ) + } + + const [, setSearchParams] = useSearchParams() + const handleOpenChange = (open: boolean) => { + if (!open) { + setConditionType(null) + setSearchParams( + {}, + { + replace: true, + } + ) + } + + setOpen(open) + } + + return ( + +
+ +
+ + + + + +
+
+ + + + +
+
+
+ + {t("taxRates.create.title")} + + + + {t("taxRates.create.description")} + +
+ + { + return ( + + {t("fields.province")} + + + + + + + ) + }} + /> + + { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + + { + return ( + + {t("fields.rate")} + + + { + if (e.target.value) { + field.onChange(parseInt(e.target.value)) + } + }} + /> + + + ) + }} + /> + + { + return ( + + {t("fields.code")} + + + + + + ) + }} + /> + + {taxRegion.parent_id && ( + { + return ( + + + {t("taxRates.fields.isCombinable")} + + + + + + + ) + }} + /> + )} + +
+ {selectedTypes.length > 0 && ( +
+ {selectedTypes.map((selectedType) => { + if (selectedType in (selectedConditionTypes || {})) { + const field = form.getValues(selectedType) || [] + + return ( + f.label)} + onClick={() => { + setConditionType(selectedType) + setOpen(true) + }} + /> + ) + } + })} +
+ )} + +
+ { + v && setIsDropdownOpen(v) + }} + > + + + + + { + setIsDropdownOpen(true) + }} + > + {Object.values(ConditionEntities).map((type) => ( + + toggleSelectedConditionTypes(type) + } + > + + {t(`fields.${type}`)} + + + ))} + + + + {selectedTypes.length > 0 && ( + + )} +
+
+
+
+
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/index.ts new file mode 100644 index 0000000000..72ee1c8329 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/index.ts @@ -0,0 +1,3 @@ +export * from "./tax-rate-create" + +export { TaxRateCreate as Component } from "./tax-rate-create" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/tax-rate-create.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/tax-rate-create.tsx new file mode 100644 index 0000000000..37d03e5206 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-create/tax-rate-create.tsx @@ -0,0 +1,21 @@ +import { useParams } from "react-router-dom" +import { RouteFocusModal } from "../../../components/route-modal" +import { useTaxRegion } from "../../../hooks/api/tax-regions" +import { TaxRateCreateForm } from "./components" + +export const TaxRateCreate = () => { + const params = useParams() + const { tax_region: taxRegion, isError, error } = useTaxRegion(params.id!) + + if (isError) { + throw error + } + + return ( + taxRegion && ( + + + + ) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/components/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/components/index.ts new file mode 100644 index 0000000000..8f2ab9b263 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/components/index.ts @@ -0,0 +1 @@ +export * from "./tax-rate-edit-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/components/tax-rate-edit-form/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/components/tax-rate-edit-form/index.ts new file mode 100644 index 0000000000..8f2ab9b263 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/components/tax-rate-edit-form/index.ts @@ -0,0 +1 @@ +export * from "./tax-rate-edit-form" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/components/tax-rate-edit-form/tax-rate-edit-form.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/components/tax-rate-edit-form/tax-rate-edit-form.tsx new file mode 100644 index 0000000000..b01e4e3d17 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/components/tax-rate-edit-form/tax-rate-edit-form.tsx @@ -0,0 +1,488 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { + Button, + clx, + DropdownMenu, + Heading, + Input, + Switch, + Text, +} from "@medusajs/ui" +import { useForm, useWatch } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" + +import { TaxRateResponse, TaxRegionResponse } from "@medusajs/types" +import { useState } from "react" +import { useSearchParams } from "react-router-dom" +import { Form } from "../../../../../components/common/form" +import { PercentageInput } from "../../../../../components/common/percentage-input" +import { SplitView } from "../../../../../components/layout/split-view" +import { + RouteFocusModal, + useRouteModal, +} from "../../../../../components/route-modal" +import { useUpdateTaxRate } from "../../../../../hooks/api/tax-rates" +import { ConditionsDrawer } from "../../../common/components/conditions-drawer" +import { ConditionEntities, Operators } from "../../../common/constants" +import { ConditionsOption } from "../../../common/types" +import { Condition } from "../../../tax-rate-create/components" + +const SelectedConditionTypesSchema = zod.object({ + [ConditionEntities.PRODUCT]: zod.boolean(), + [ConditionEntities.PRODUCT_TYPE]: zod.boolean(), + [ConditionEntities.PRODUCT_COLLECTION]: zod.boolean(), + [ConditionEntities.PRODUCT_TAG]: zod.boolean(), + [ConditionEntities.CUSTOMER_GROUP]: zod.boolean(), +}) + +const ResourceSchema = zod.array( + zod.object({ + label: zod.string(), + value: zod.string(), + }) +) + +const UpdateTaxRateSchema = zod.object({ + name: zod.string().optional(), + code: zod.string().optional(), + rate: zod.number().optional(), + is_combinable: zod.boolean().optional(), + selected_condition_types: SelectedConditionTypesSchema, + products: ResourceSchema, + product_types: ResourceSchema, + product_collections: ResourceSchema, + product_tags: ResourceSchema, + customer_groups: ResourceSchema, +}) + +export const TaxRateEditForm = ({ + taxRegion, + taxRate, +}: { + taxRegion: TaxRegionResponse + taxRate: TaxRateResponse +}) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + const productRules = taxRate.rules?.filter((r) => r.reference == "product") + const productTypeRules = taxRate.rules?.filter( + (r) => r.reference == "product_type" + ) + const productCollectionRules = taxRate.rules?.filter( + (r) => r.reference == "product_collection" + ) + const productTagRules = taxRate.rules?.filter( + (r) => r.reference == "product_tag" + ) + const customerGroupRules = taxRate.rules?.filter( + (r) => r.reference == "customer_group" + ) + + const form = useForm>({ + defaultValues: { + name: taxRate.name, + code: taxRate.code || undefined, + rate: taxRate.rate || undefined, + is_combinable: taxRate.is_combinable, + selected_condition_types: { + [ConditionEntities.PRODUCT]: !!productRules?.length, + [ConditionEntities.PRODUCT_TYPE]: !!productTypeRules?.length, + [ConditionEntities.PRODUCT_COLLECTION]: + !!productCollectionRules?.length, + [ConditionEntities.PRODUCT_TAG]: !!productTagRules?.length, + [ConditionEntities.CUSTOMER_GROUP]: !!customerGroupRules?.length, + }, + products: productRules.map((r) => ({ + label: r.reference, + value: r.reference_id, + })), + product_types: productTypeRules.map((r) => ({ + label: r.reference, + value: r.reference_id, + })), + product_collections: productCollectionRules.map((r) => ({ + label: r.reference, + value: r.reference_id, + })), + product_tags: productTagRules.map((r) => ({ + label: r.reference, + value: r.reference_id, + })), + customer_groups: customerGroupRules.map((r) => ({ + label: r.reference, + value: r.reference_id, + })), + }, + resolver: zodResolver(UpdateTaxRateSchema), + }) + + const { mutateAsync, isPending } = useUpdateTaxRate(taxRate.id) + + const buildRules = (key: string, data: { value: string }[]) => + data?.map((product) => ({ + reference: key, + reference_id: product.value, + })) || [] + + const handleSubmit = form.handleSubmit(async (data) => { + const rules = [ + ...buildRules("product", data.products), + ...buildRules("product_type", data.product_types), + ...buildRules("product_collection", data.product_collections), + ...buildRules("product_tag", data.product_tags), + ...buildRules("customer_group", data.customer_groups), + ] + + await mutateAsync( + { + name: data.name, + code: data.code || undefined, + rate: data.rate, + is_combinable: data.is_combinable, + rules, + }, + { + onSuccess: () => handleSuccess(`/settings/taxes/${taxRegion.id}`), + } + ) + }) + + const [open, setOpen] = useState(false) + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const [conditionType, setConditionType] = useState( + null + ) + const selectedConditionTypes = useWatch({ + name: "selected_condition_types", + control: form.control, + }) + + const selectedProducts = useWatch({ + control: form.control, + name: "products", + }) + + const selectedProductCollections = useWatch({ + control: form.control, + name: "product_collections", + }) + + const selectedProductTypes = useWatch({ + control: form.control, + name: "product_types", + }) + + const selectedProductTags = useWatch({ + control: form.control, + name: "product_tags", + }) + + const selectedCustomerGroups = useWatch({ + control: form.control, + name: "customer_groups", + }) + + const handleSaveConditions = (type: ConditionEntities) => { + return (options: ConditionsOption[]) => { + form.setValue(type, options, { + shouldDirty: true, + shouldTouch: true, + }) + + setOpen(false) + } + } + + const selectedTypes = Object.keys(selectedConditionTypes || {}) + .filter( + (k) => selectedConditionTypes[k as keyof typeof selectedConditionTypes] + ) + .sort() as ConditionEntities[] + + const handleOpenDrawer = (type: ConditionEntities, operator: Operators) => { + setConditionType(type) + setOpen(true) + } + + const toggleSelectedConditionTypes = (type: ConditionEntities) => { + const state = { ...form.getValues().selected_condition_types } + if (state[type]) { + delete state[type] + } else { + state[type] = true + } + + form.setValue("selected_condition_types", state, { + shouldDirty: true, + shouldTouch: true, + }) + } + + const clearAllSelectedConditions = () => { + form.setValue( + "selected_condition_types", + { + [ConditionEntities.PRODUCT]: false, + [ConditionEntities.PRODUCT_TYPE]: false, + [ConditionEntities.PRODUCT_COLLECTION]: false, + [ConditionEntities.PRODUCT_TAG]: false, + [ConditionEntities.CUSTOMER_GROUP]: false, + }, + { + shouldDirty: true, + shouldTouch: true, + } + ) + } + + const [, setSearchParams] = useSearchParams() + const handleOpenChange = (open: boolean) => { + if (!open) { + setConditionType(null) + setSearchParams( + {}, + { + replace: true, + } + ) + } + + setOpen(open) + } + + return ( + +
+ +
+ + + + +
+
+ + + + +
+
+
+ + {t("taxRates.edit.title")} + + + + {t("taxRates.edit.description")} + +
+ + { + return ( + + {t("fields.name")} + + + + + + ) + }} + /> + + { + return ( + + {t("fields.rate")} + + + { + if (e.target.value) { + field.onChange(parseInt(e.target.value)) + } + }} + /> + + + ) + }} + /> + + { + return ( + + {t("fields.code")} + + + + + + ) + }} + /> + + {taxRate.tax_region?.parent_id && ( + { + return ( + + + {t("taxRates.fields.isCombinable")} + + + + + + + ) + }} + /> + )} + + {!taxRate.is_default && ( +
+ {selectedTypes.length > 0 && ( +
+ {selectedTypes.map((selectedType) => { + if ( + selectedType in (selectedConditionTypes || {}) + ) { + const field = form.getValues(selectedType) || [] + + return ( + f.value)} + onClick={() => { + setConditionType(selectedType) + setOpen(true) + }} + /> + ) + } + })} +
+ )} + +
+ { + v && setIsDropdownOpen(v) + }} + > + + + + + setIsDropdownOpen(false)} + > + {Object.values(ConditionEntities).map((type) => ( + + toggleSelectedConditionTypes(type) + } + > + + {t(`fields.${type}`)} + + + ))} + + + + {selectedTypes.length > 0 && ( + + )} +
+
+ )} +
+
+
+ + + + +
+
+
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/index.ts new file mode 100644 index 0000000000..ef8328fd8b --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/index.ts @@ -0,0 +1,4 @@ +export * from "./tax-rate-edit" + +export { taxRateLoader as loader } from "./loader" +export { TaxRateEdit as Component } from "./tax-rate-edit" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/loader.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/loader.ts new file mode 100644 index 0000000000..3dfc7feb62 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/loader.ts @@ -0,0 +1,21 @@ +import { LoaderFunctionArgs } from "react-router-dom" + +import { AdminTaxRateResponse } from "@medusajs/types" +import { taxRatesQueryKeys } from "../../../hooks/api/tax-rates" +import { client } from "../../../lib/client" +import { queryClient } from "../../../lib/medusa" + +const taxRateDetailQuery = (id: string) => ({ + queryKey: taxRatesQueryKeys.detail(id), + queryFn: async () => client.taxes.retrieveTaxRate(id), +}) + +export const taxRateLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.taxRateId + const query = taxRateDetailQuery(id!) + + return ( + queryClient.getQueryData(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/tax-rate-edit.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/tax-rate-edit.tsx new file mode 100644 index 0000000000..a6196b38af --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-rate-edit/tax-rate-edit.tsx @@ -0,0 +1,34 @@ +import { useParams } from "react-router-dom" +import { RouteFocusModal } from "../../../components/route-modal" +import { useTaxRate } from "../../../hooks/api/tax-rates" +import { useTaxRegion } from "../../../hooks/api/tax-regions" +import { TaxRateEditForm } from "./components" + +export const TaxRateEdit = () => { + const params = useParams() + + const { tax_region: taxRegion } = useTaxRegion(params.id!) + const { + tax_rate: taxRate, + isLoading, + isError, + error, + } = useTaxRate(params.taxRateId!) + + if (isLoading) { + return
Loading...
+ } + + if (isError) { + throw error + } + + return ( + taxRegion && + taxRate && ( + + + + ) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-create/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-create/index.ts new file mode 100644 index 0000000000..26d1bff91c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-create/index.ts @@ -0,0 +1,3 @@ +export * from "./tax-region-create" + +export { TaxRegionCreate as Component } from "./tax-region-create" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-create/tax-region-create.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-create/tax-region-create.tsx new file mode 100644 index 0000000000..ae6a4115f6 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-create/tax-region-create.tsx @@ -0,0 +1,21 @@ +import * as zod from "zod" +import { RouteFocusModal } from "../../../components/route-modal" +import { TaxRegionCreateForm } from "../common/components/tax-region-create-form" + +const CreateTaxRegionForm = zod.object({ + province_code: zod.string().optional(), + country_code: zod.string(), + parent_id: zod.string().optional(), + name: zod.string(), + code: zod.string().optional(), + rate: zod.number(), + is_combinable: zod.boolean().default(false), +}) + +export const TaxRegionCreate = () => { + return ( + + + + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/index.ts new file mode 100644 index 0000000000..7c45c4182c --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/index.ts @@ -0,0 +1,2 @@ +export * from "./tax-rate-list" +export * from "./tax-region-general-detail" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-rate-list/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-rate-list/index.ts new file mode 100644 index 0000000000..9e413d7d01 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-rate-list/index.ts @@ -0,0 +1 @@ +export * from "./tax-rate-list" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-rate-list/tax-rate-list.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-rate-list/tax-rate-list.tsx new file mode 100644 index 0000000000..ea96bea4a4 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-rate-list/tax-rate-list.tsx @@ -0,0 +1,214 @@ +import { PencilSquare, Trash } from "@medusajs/icons" +import { TaxRateResponse, TaxRegionResponse } from "@medusajs/types" +import { Button, Container, Heading, usePrompt } from "@medusajs/ui" +import { keepPreviousData } from "@tanstack/react-query" +import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { DataTable } from "../../../../../components/table/data-table" +import { + useDeleteTaxRate, + useTaxRates, +} from "../../../../../hooks/api/tax-rates" +import { useDeleteTaxRegion } from "../../../../../hooks/api/tax-regions" +import { useTaxRateTableColumns } from "../../../../../hooks/table/columns/use-tax-rates-table-columns" +import { useTaxRateTableFilters } from "../../../../../hooks/table/filters/use-tax-rate-table-filters" +import { useTaxRateTableQuery } from "../../../../../hooks/table/query/use-tax-rate-table-query" +import { useDataTable } from "../../../../../hooks/use-data-table" + +const PAGE_SIZE = 10 + +type TaxRateListProps = { + taxRegion: TaxRegionResponse + isDefault: boolean +} + +export const TaxRateList = ({ + taxRegion, + isDefault = false, +}: TaxRateListProps) => { + const [rowSelection, setRowSelection] = useState({}) + + const { searchParams, raw } = useTaxRateTableQuery({ pageSize: PAGE_SIZE }) + const childrenIds = taxRegion.children?.map((c) => c.id) || [] + const { + tax_rates: taxRates, + count, + isLoading, + isError, + error, + } = useTaxRates( + { + ...searchParams, + tax_region_id: [taxRegion.id, ...childrenIds], + is_default: isDefault, + }, + { + placeholderData: keepPreviousData, + } + ) + + const columns = useColumns() + const filters = useTaxRateTableFilters() + + const { table } = useDataTable({ + data: taxRates ?? [], + columns, + count, + enablePagination: true, + enableRowSelection: true, + pageSize: PAGE_SIZE, + getRowId: (row) => row.id, + rowSelection: { + state: rowSelection, + updater: setRowSelection, + }, + meta: { + taxRegionId: taxRegion.id, + }, + }) + + const { mutateAsync } = useDeleteTaxRegion(taxRegion.id) + + const prompt = usePrompt() + const { t } = useTranslation() + + const handleRemove = async () => { + const result = await prompt({ + title: t("general.areYouSure"), + description: t("taxRegions.removeWarning", { + tax_region_name: taxRegion.name, + }), + confirmText: t("actions.delete"), + cancelText: t("actions.cancel"), + }) + + if (!result) { + return + } + + await mutateAsync(undefined, { + onSuccess: () => { + setRowSelection({}) + }, + }) + } + + if (isError) { + throw error + } + + return ( + +
+ + {isDefault ? `Default ${t("taxRates.domain")}` : `Tax Rate Overrides`} + + + + + +
+ + + `/settings/taxes/${taxRegion.id}/tax-rates/${row.id}/edit` + } + isLoading={isLoading} + orderBy={["is_default", "rate", "created_at", "updated_at"]} + queryObject={raw} + /> +
+ ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = useTaxRateTableColumns() + + return useMemo( + () => [ + ...base, + columnHelper.display({ + id: "actions", + cell: ({ row, table }) => { + const { taxRegionId } = table.options.meta as { + taxRegionId: string + } + + return ( + + ) + }, + }), + ], + [base] + ) +} + +const TaxRateListActions = ({ + taxRateId, + taxRegionId, +}: { + taxRateId: string + taxRegionId: string +}) => { + const { t } = useTranslation() + + const { mutateAsync } = useDeleteTaxRate(taxRateId) + + const onRemove = async () => await mutateAsync() + + return ( + , + label: t("actions.edit"), + to: `/settings/taxes/${taxRegionId}/tax-rates/${taxRateId}/edit`, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("actions.remove"), + onClick: onRemove, + }, + ], + }, + ]} + /> + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-region-general-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-region-general-detail/index.ts new file mode 100644 index 0000000000..c9f34fc23f --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-region-general-detail/index.ts @@ -0,0 +1 @@ +export * from "./tax-region-general-detail" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-region-general-detail/tax-region-general-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-region-general-detail/tax-region-general-detail.tsx new file mode 100644 index 0000000000..3300174685 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/components/tax-region-general-detail/tax-region-general-detail.tsx @@ -0,0 +1,41 @@ +import { TaxRegionResponse } from "@medusajs/types" +import { Container, Heading, Text } from "@medusajs/ui" +import { useTranslation } from "react-i18next" +import { formatDate } from "../../../../../components/common/date" +import { getCountryByIso2 } from "../../../../../lib/countries" + +type TaxRegionGeneralDetailProps = { + taxRegion: TaxRegionResponse +} + +export const TaxRegionGeneralDetail = ({ + taxRegion, +}: TaxRegionGeneralDetailProps) => { + const { t } = useTranslation() + const countryCode = taxRegion.parent?.country_code || taxRegion.country_code + const displayName = getCountryByIso2(countryCode)?.display_name || countryCode + + return ( + +
+
+ {displayName} + + + {t("taxRegions.description")} + +
+
+ +
+ + {t("fields.created")} + + + + {formatDate(taxRegion.created_at)} + +
+
+ ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/index.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/index.ts new file mode 100644 index 0000000000..38f10d7514 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/index.ts @@ -0,0 +1,4 @@ +export * from "./tax-region-detail" + +export { taxRegionLoader as loader } from "./loader" +export { TaxRegionDetail as Component } from "./tax-region-detail" diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/loader.ts b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/loader.ts new file mode 100644 index 0000000000..ba3e896841 --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/loader.ts @@ -0,0 +1,21 @@ +import { LoaderFunctionArgs } from "react-router-dom" + +import { AdminTaxRegionResponse } from "@medusajs/types" +import { taxRegionsQueryKeys } from "../../../hooks/api/tax-regions" +import { client } from "../../../lib/client" +import { queryClient } from "../../../lib/medusa" + +const taxRegionDetailQuery = (id: string) => ({ + queryKey: taxRegionsQueryKeys.detail(id), + queryFn: async () => client.taxes.retrieveTaxRegion(id), +}) + +export const taxRegionLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = taxRegionDetailQuery(id!) + + return ( + queryClient.getQueryData(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/tax-region-detail.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/tax-region-detail.tsx new file mode 100644 index 0000000000..eaf249b3ab --- /dev/null +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-detail/tax-region-detail.tsx @@ -0,0 +1,30 @@ +import { Outlet, useParams } from "react-router-dom" +import { JsonViewSection } from "../../../components/common/json-view-section" +import { useTaxRegion } from "../../../hooks/api/tax-regions" +import { TaxRateList } from "./components/tax-rate-list" +import { TaxRegionGeneralDetail } from "./components/tax-region-general-detail" + +export const TaxRegionDetail = () => { + const { id } = useParams() + const { tax_region: taxRegion, isLoading, isError, error } = useTaxRegion(id!) + + if (isLoading) { + return
Loading...
+ } + + if (isError) { + throw error + } + + return ( + taxRegion && ( +
+ + + + + +
+ ) + ) +} diff --git a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-list/components/region-list-table/tax-region-list-table.tsx b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-list/components/region-list-table/tax-region-list-table.tsx index 95b66eedd8..dd424ea18a 100644 --- a/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-list/components/region-list-table/tax-region-list-table.tsx +++ b/packages/admin-next/dashboard/src/v2-routes/taxes/tax-region-list/components/region-list-table/tax-region-list-table.tsx @@ -1,31 +1,34 @@ -import { PencilSquare, Trash } from "@medusajs/icons" -import { Button, Container, Heading, usePrompt } from "@medusajs/ui" +import { Trash } from "@medusajs/icons" +import { AdminTaxRegionResponse } from "@medusajs/types" +import { Button, Container, Heading } from "@medusajs/ui" import { createColumnHelper } from "@tanstack/react-table" +import { t } from "i18next" import { useMemo } from "react" import { useTranslation } from "react-i18next" -import { Link } from "react-router-dom" -import { AdminTaxRegionResponse } from "@medusajs/types" +import { Link, useNavigate } from "react-router-dom" import { ActionMenu } from "../../../../../components/common/action-menu" import { DataTable } from "../../../../../components/table/data-table" -import { useRegionTableFilters } from "../../../../../hooks/table/filters/use-region-table-filters" -import { useDataTable } from "../../../../../hooks/use-data-table" -import { useDeleteRegion } from "../../../../../hooks/api/regions" -import { useTaxRegions } from "../../../../../hooks/api/tax-regions" +import { + useDeleteTaxRegion, + useTaxRegions, +} from "../../../../../hooks/api/tax-regions" import { useTaxRegionTableQuery } from "../../../../../hooks/table/query/use-tax-region-table-query copy" -import { t } from "i18next" -import { DateCell } from "../../../../../components/table/table-cells/common/date-cell" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { getCountryByIso2 } from "../../../../../lib/countries" const PAGE_SIZE = 20 export const TaxRegionListTable = () => { const { t } = useTranslation() - const { searchParams, raw } = useTaxRegionTableQuery({ pageSize: PAGE_SIZE }) + const { searchParams, raw } = useTaxRegionTableQuery({ + pageSize: PAGE_SIZE, + }) const { tax_regions, count, isLoading, isError, error } = useTaxRegions({ ...searchParams, + parent_id: "null", }) - const filters = useRegionTableFilters() const columns = useColumns() const { table } = useDataTable({ @@ -45,11 +48,9 @@ export const TaxRegionListTable = () => {
{t("taxes.domain")} - - - +
{ count={count} pageSize={PAGE_SIZE} isLoading={isLoading} - filters={filters} - orderBy={["name", "created_at", "updated_at"]} navigateTo={(row) => `${row.original.id}`} pagination - search queryObject={raw} />
@@ -74,6 +72,16 @@ const TaxRegionActions = ({ taxRegion: AdminTaxRegionResponse["tax_region"] }) => { const { t } = useTranslation() + const navigate = useNavigate() + const { mutateAsync } = useDeleteTaxRegion(taxRegion.id) + + const handleDelete = async () => { + await mutateAsync(undefined, { + onSuccess: () => { + navigate("/settings/taxes", { replace: true }) + }, + }) + } return ( , + icon: , + label: t("actions.delete"), + onClick: handleDelete, }, ], }, @@ -97,20 +105,18 @@ const columnHelper = createColumnHelper() const useColumns = () => { return useMemo( () => [ - columnHelper.accessor("name", { - header: t("fields.code"), - cell: ({ getValue }) => ( -
- {getValue()} -
- ), - }), - columnHelper.accessor("created_at", { - header: t("fields.created"), + columnHelper.accessor("country_code", { + header: t("fields.country"), cell: ({ getValue }) => { - const date = getValue() + const countryCode = getValue() + const displayName = + getCountryByIso2(countryCode)?.display_name || countryCode - return + return ( +
+ {displayName} +
+ ) }, }), columnHelper.display({ diff --git a/packages/medusa/src/api-v2/admin/tax-rates/[id]/route.ts b/packages/medusa/src/api-v2/admin/tax-rates/[id]/route.ts index c8be812b48..89df349cc5 100644 --- a/packages/medusa/src/api-v2/admin/tax-rates/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/tax-rates/[id]/route.ts @@ -1,21 +1,20 @@ -import { - AuthenticatedMedusaRequest, - MedusaResponse, -} from "../../../../types/routing" - -import { - remoteQueryObjectFromString, - ContainerRegistrationKeys, -} from "@medusajs/utils" import { deleteTaxRatesWorkflow, updateTaxRatesWorkflow, } from "@medusajs/core-flows" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../types/routing" +import { refetchTaxRate } from "../helpers" import { AdminGetTaxRateParamsType, AdminUpdateTaxRateType, } from "../validators" -import { refetchTaxRate } from "../helpers" export const POST = async ( req: AuthenticatedMedusaRequest, diff --git a/packages/medusa/src/api-v2/admin/tax-rates/middlewares.ts b/packages/medusa/src/api-v2/admin/tax-rates/middlewares.ts index 1a8d4274b9..63e810ef8d 100644 --- a/packages/medusa/src/api-v2/admin/tax-rates/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/tax-rates/middlewares.ts @@ -1,5 +1,6 @@ import * as QueryConfig from "./query-config" +import { validateAndTransformQuery } from "../../utils/validate-query" import { AdminCreateTaxRate, AdminCreateTaxRateRule, @@ -11,7 +12,6 @@ import { import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" import { validateAndTransformBody } from "../../utils/validate-body" -import { validateAndTransformQuery } from "../../utils/validate-query" export const adminTaxRateRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -41,16 +41,6 @@ export const adminTaxRateRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, - { - method: "GET", - matcher: "/admin/tax-rates", - middlewares: [ - validateAndTransformQuery( - AdminGetTaxRatesParams, - QueryConfig.listTransformQueryConfig - ), - ], - }, { method: "GET", matcher: "/admin/tax-rates/:id", @@ -61,6 +51,16 @@ export const adminTaxRateRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: "GET", + matcher: "/admin/tax-rates", + middlewares: [ + validateAndTransformQuery( + AdminGetTaxRatesParams, + QueryConfig.listTransformQueryConfig + ), + ], + }, { method: "POST", matcher: "/admin/tax-rates/:id/rules", diff --git a/packages/medusa/src/api-v2/admin/tax-rates/query-config.ts b/packages/medusa/src/api-v2/admin/tax-rates/query-config.ts index b405ddc44c..c2bda15988 100644 --- a/packages/medusa/src/api-v2/admin/tax-rates/query-config.ts +++ b/packages/medusa/src/api-v2/admin/tax-rates/query-config.ts @@ -1,6 +1,4 @@ -export const defaultAdminTaxRateRelations = [] -export const allowedAdminTaxRateRelations = [] -export const defaultAdminTaxRateFields = [ +export const defaults = [ "id", "name", "code", @@ -13,14 +11,16 @@ export const defaultAdminTaxRateFields = [ "updated_at", "deleted_at", "metadata", + "*tax_region", + "*rules", ] export const retrieveTransformQueryConfig = { - defaults: defaultAdminTaxRateFields, + defaults, isList: false, } export const listTransformQueryConfig = { - defaults: defaultAdminTaxRateFields, + defaults, isList: true, } diff --git a/packages/medusa/src/api-v2/admin/tax-rates/route.ts b/packages/medusa/src/api-v2/admin/tax-rates/route.ts index 75579d9d0d..4553826d72 100644 --- a/packages/medusa/src/api-v2/admin/tax-rates/route.ts +++ b/packages/medusa/src/api-v2/admin/tax-rates/route.ts @@ -1,17 +1,17 @@ import { createTaxRatesWorkflow } from "@medusajs/core-flows" import { - remoteQueryObjectFromString, ContainerRegistrationKeys, + remoteQueryObjectFromString, } from "@medusajs/utils" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../types/routing" +import { refetchTaxRate } from "./helpers" import { AdminCreateTaxRateType, AdminGetTaxRatesParamsType, } from "./validators" -import { refetchTaxRate } from "./helpers" export const POST = async ( req: AuthenticatedMedusaRequest, diff --git a/packages/medusa/src/api-v2/admin/tax-rates/validators.ts b/packages/medusa/src/api-v2/admin/tax-rates/validators.ts index f84a403516..5420fcfd2a 100644 --- a/packages/medusa/src/api-v2/admin/tax-rates/validators.ts +++ b/packages/medusa/src/api-v2/admin/tax-rates/validators.ts @@ -1,5 +1,9 @@ -import { createFindParams, createSelectParams } from "../../utils/validators" import { z } from "zod" +import { + createFindParams, + createOperatorMap, + createSelectParams, +} from "../../utils/validators" export type AdminGetTaxRateParamsType = z.infer export const AdminGetTaxRateParams = createSelectParams() @@ -8,7 +12,20 @@ export type AdminGetTaxRatesParamsType = z.infer export const AdminGetTaxRatesParams = createFindParams({ limit: 20, offset: 0, -}) +}).merge( + z.object({ + q: z.string().optional(), + tax_region_id: z + .union([z.string(), z.array(z.string()), createOperatorMap()]) + .optional(), + is_default: z.union([z.literal("true"), z.literal("false")]).optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), + $and: z.lazy(() => AdminGetTaxRatesParams.array()).optional(), + $or: z.lazy(() => AdminGetTaxRatesParams.array()).optional(), + }) +) export type AdminCreateTaxRateRuleType = z.infer export const AdminCreateTaxRateRule = z.object({ diff --git a/packages/medusa/src/api-v2/admin/tax-regions/[id]/route.ts b/packages/medusa/src/api-v2/admin/tax-regions/[id]/route.ts index d23d7f9eec..4e4697953e 100644 --- a/packages/medusa/src/api-v2/admin/tax-regions/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/tax-regions/[id]/route.ts @@ -1,9 +1,31 @@ import { deleteTaxRegionsWorkflow } from "@medusajs/core-flows" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../../types/routing" +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const filters = { id: req.params.id } + const [taxRegion] = await remoteQuery( + remoteQueryObjectFromString({ + entryPoint: "tax_region", + variables: { filters }, + fields: req.remoteQueryConfig.fields, + }) + ) + + res.status(200).json({ tax_region: taxRegion }) +} + export const DELETE = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse diff --git a/packages/medusa/src/api-v2/admin/tax-regions/middlewares.ts b/packages/medusa/src/api-v2/admin/tax-regions/middlewares.ts index fc149ee14f..288dcd9245 100644 --- a/packages/medusa/src/api-v2/admin/tax-regions/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/tax-regions/middlewares.ts @@ -1,6 +1,10 @@ import * as QueryConfig from "./query-config" -import { AdminCreateTaxRegion, AdminGetTaxRegionsParams } from "./validators" +import { + AdminCreateTaxRegion, + AdminGetTaxRegionParams, + AdminGetTaxRegionsParams, +} from "./validators" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { authenticate } from "../../../utils/authenticate-middleware" @@ -34,4 +38,14 @@ export const adminTaxRegionRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: "GET", + matcher: "/admin/tax-regions/:id", + middlewares: [ + validateAndTransformQuery( + AdminGetTaxRegionParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/tax-regions/query-config.ts b/packages/medusa/src/api-v2/admin/tax-regions/query-config.ts index 73ef61ff25..1173d47075 100644 --- a/packages/medusa/src/api-v2/admin/tax-regions/query-config.ts +++ b/packages/medusa/src/api-v2/admin/tax-regions/query-config.ts @@ -9,6 +9,12 @@ export const defaults = [ "updated_at", "deleted_at", "metadata", + "*children", + "*children.tax_rates", + "*children.tax_rates.rules", + "*parent", + "*tax_rates", + "*tax_rates.rules", ] export const retrieveTransformQueryConfig = { diff --git a/packages/medusa/src/api-v2/admin/tax-regions/validators.ts b/packages/medusa/src/api-v2/admin/tax-regions/validators.ts index 834ff1dec6..11bf4f3b87 100644 --- a/packages/medusa/src/api-v2/admin/tax-regions/validators.ts +++ b/packages/medusa/src/api-v2/admin/tax-regions/validators.ts @@ -19,9 +19,16 @@ export const AdminGetTaxRegionsParams = createFindParams({ }).merge( z.object({ id: z.union([z.string(), z.array(z.string())]).optional(), - country_code: z.union([z.string(), z.array(z.string())]).optional(), - province_code: z.union([z.string(), z.array(z.string())]).optional(), - parent_id: z.union([z.string(), z.array(z.string())]).optional(), + q: z.string().optional(), + country_code: z + .union([z.string(), z.array(z.string()), createOperatorMap()]) + .optional(), + province_code: z + .union([z.string(), z.array(z.string()), createOperatorMap()]) + .optional(), + parent_id: z + .union([z.string(), z.array(z.string()), createOperatorMap()]) + .optional(), created_by: z.union([z.string(), z.array(z.string())]).optional(), created_at: createOperatorMap().optional(), updated_at: createOperatorMap().optional(), @@ -41,6 +48,9 @@ export const AdminCreateTaxRegion = z.object({ rate: z.number().optional(), code: z.string().optional(), name: z.string(), + is_combinable: z + .union([z.literal("true"), z.literal("false")]) + .optional(), metadata: z.record(z.unknown()).optional(), }) .optional(), diff --git a/packages/tax/src/joiner-config.ts b/packages/tax/src/joiner-config.ts index d89a3bda3e..5f47703061 100644 --- a/packages/tax/src/joiner-config.ts +++ b/packages/tax/src/joiner-config.ts @@ -1,7 +1,7 @@ import { Modules } from "@medusajs/modules-sdk" import { ModuleJoinerConfig } from "@medusajs/types" import { MapToConfig } from "@medusajs/utils" -import { TaxRate, TaxRegion, TaxRateRule, TaxProvider } from "@models" +import { TaxProvider, TaxRate, TaxRateRule, TaxRegion } from "@models" export const LinkableKeys: Record = { tax_rate_id: TaxRate.name, diff --git a/packages/tax/src/models/tax-rate.ts b/packages/tax/src/models/tax-rate.ts index 6add3d5693..8b2e6f8212 100644 --- a/packages/tax/src/models/tax-rate.ts +++ b/packages/tax/src/models/tax-rate.ts @@ -1,24 +1,25 @@ import { DAL } from "@medusajs/types" import { - DALUtils, createPsqlIndexStatementHelper, + DALUtils, generateEntityId, + Searchable, } from "@medusajs/utils" import { - Filter, BeforeCreate, Cascade, Collection, Entity, + Filter, ManyToOne, - OnInit, OneToMany, + OnInit, OptionalProps, PrimaryKey, Property, } from "@mikro-orm/core" -import TaxRegion from "./tax-region" import TaxRateRule from "./tax-rate-rule" +import TaxRegion from "./tax-region" type OptionalTaxRateProps = DAL.SoftDeletableEntityDateColumns @@ -53,9 +54,11 @@ export default class TaxRate { @Property({ columnType: "real", nullable: true }) rate: number | null = null + @Searchable() @Property({ columnType: "text", nullable: true }) code: string | null = null + @Searchable() @Property({ columnType: "text" }) name: string @@ -66,7 +69,7 @@ export default class TaxRate { is_combinable = false @ManyToOne(() => TaxRegion, { - type: "text", + columnType: "text", fieldName: "tax_region_id", mapToPk: true, onDelete: "cascade", @@ -79,7 +82,6 @@ export default class TaxRate { @OneToMany(() => TaxRateRule, (rule) => rule.tax_rate, { cascade: ["soft-remove" as Cascade], - persist: false, }) rules = new Collection(this) diff --git a/packages/tax/src/models/tax-region.ts b/packages/tax/src/models/tax-region.ts index 4d58a6566d..f053a40edc 100644 --- a/packages/tax/src/models/tax-region.ts +++ b/packages/tax/src/models/tax-region.ts @@ -1,25 +1,26 @@ import { DAL } from "@medusajs/types" import { DALUtils, + Searchable, createPsqlIndexStatementHelper, generateEntityId, } from "@medusajs/utils" import { - Filter, BeforeCreate, + Cascade, + Check, Collection, Entity, + Filter, + ManyToOne, OnInit, + OneToMany, OptionalProps, PrimaryKey, Property, - OneToMany, - ManyToOne, - Check, - Cascade, } from "@mikro-orm/core" -import TaxRate from "./tax-rate" import TaxProvider from "./tax-provider" +import TaxRate from "./tax-rate" type OptionalTaxRegionProps = DAL.SoftDeletableEntityDateColumns @@ -63,9 +64,14 @@ export default class TaxRegion { }) provider_id: string | null = null + @ManyToOne(() => TaxProvider, { persist: false }) + provider: TaxProvider + + @Searchable() @Property({ columnType: "text" }) country_code: string + @Searchable() @Property({ columnType: "text", nullable: true }) province_code: string | null = null diff --git a/packages/types/src/http/tax/admin/index.ts b/packages/types/src/http/tax/admin/index.ts index d69cd9a33b..a0d80a2f66 100644 --- a/packages/types/src/http/tax/admin/index.ts +++ b/packages/types/src/http/tax/admin/index.ts @@ -1 +1,2 @@ +export * from "./tax-rates" export * from "./tax-regions" diff --git a/packages/types/src/http/tax/admin/tax-rates.ts b/packages/types/src/http/tax/admin/tax-rates.ts new file mode 100644 index 0000000000..56eb031bcd --- /dev/null +++ b/packages/types/src/http/tax/admin/tax-rates.ts @@ -0,0 +1,39 @@ +import { PaginatedResponse } from "../../../common" +import { TaxRegionResponse } from "./tax-regions" + +/** + * @experimental + */ +export interface TaxRateResponse { + id: string + rate: number | null + code: string | null + name: string + metadata: Record | null + tax_region_id: string + is_combinable: boolean + is_default: boolean + created_at: string | Date + updated_at: string | Date + deleted_at: Date | null + created_by: string | null + tax_region: TaxRegionResponse + rules: { + reference: string + reference_id: string + }[] +} + +/** + * @experimental + */ +export interface AdminTaxRateResponse { + tax_rate: TaxRateResponse +} + +/** + * @experimental + */ +export interface AdminTaxRateListResponse extends PaginatedResponse { + tax_rates: TaxRateResponse[] +} diff --git a/packages/types/src/http/tax/admin/tax-regions.ts b/packages/types/src/http/tax/admin/tax-regions.ts index 44d1d02979..dcd4a85aaa 100644 --- a/packages/types/src/http/tax/admin/tax-regions.ts +++ b/packages/types/src/http/tax/admin/tax-regions.ts @@ -1,21 +1,29 @@ import { PaginatedResponse } from "../../../common" +import { TaxRateResponse } from "./tax-rates" /** * @experimental */ -interface TaxRegionResponse { +export interface TaxRegionResponse { id: string rate: number | null code: string | null + country_code: string | null + province_code: string | null name: string metadata: Record | null tax_region_id: string is_combinable: boolean is_default: boolean + parent_id: string | null created_at: string | Date updated_at: string | Date deleted_at: Date | null created_by: string | null + + tax_rates: TaxRateResponse[] + + parent: TaxRegionResponse } /**