feat(medusa,dashboard,tax): added tax rates and regions UI (#7026)

whats missing:

- make rules required for overrides
- conditions for other rules
- populating condition reference ids with labels on update

Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Riqwan Thamir
2024-04-16 10:26:12 +02:00
committed by GitHub
parent 92b633d1cb
commit 00e6b21bb5
77 changed files with 3654 additions and 104 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/medusa": patch
"@medusajs/types": patch
---
feat(medusa,types): added tax flows end to end

View File

@@ -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`,

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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)
}

View File

@@ -17,16 +17,18 @@ export const DataTableQuery = ({
prefix,
}: DataTableQueryProps) => {
return (
<div className="flex items-start justify-between gap-x-4 px-6 py-4">
<div className="w-full max-w-[60%]">
{filters && filters.length > 0 && (
<DataTableFilter filters={filters} prefix={prefix} />
)}
(search || orderBy || filters || prefix) && (
<div className="flex items-start justify-between gap-x-4 px-6 py-4">
<div className="w-full max-w-[60%]">
{filters && filters.length > 0 && (
<DataTableFilter filters={filters} prefix={prefix} />
)}
</div>
<div className="flex shrink-0 items-center gap-x-2">
{search && <DataTableSearch prefix={prefix} />}
{orderBy && <DataTableOrderBy keys={orderBy} prefix={prefix} />}
</div>
</div>
<div className="flex shrink-0 items-center gap-x-2">
{search && <DataTableSearch prefix={prefix} />}
{orderBy && <DataTableOrderBy keys={orderBy} prefix={prefix} />}
</div>
</div>
)
)
}

View File

@@ -0,0 +1 @@
export * from "./type-cell"

View File

@@ -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 (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
<span className="truncate">
{is_combinable ? (
<Badge size="2xsmall" color="green">
Combinable
</Badge>
) : (
""
)}
</span>
</div>
)
}
export const TypeHeader = ({ text }: HeaderProps) => {
return (
<div className=" flex h-full w-full items-center">
<span>{text}</span>
</div>
)
}

View File

@@ -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<string, any>,
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<string, any>,
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<TaxRateDeleteRes, Error, void>
) => {
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,
})
}

View File

@@ -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<TaxRegionDeleteRes, Error, void>
) => {
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,
})
}

View File

@@ -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<TaxRateResponse>()
export const useTaxRateTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "name",
header: () => <TextHeader text={t("fields.name")} />,
cell: ({ row }) => <TextCell text={row.original.name} />,
}),
columnHelper.display({
id: "province",
header: () => <TextHeader text={t("fields.province")} />,
cell: ({ row }) => (
<TextCell text={row.original.tax_region.province_code} />
),
}),
columnHelper.display({
id: "rate",
header: () => <TextHeader text={t("fields.rate")} />,
cell: ({ row }) => <TextCell text={`${row.original.rate} %`} />,
}),
columnHelper.display({
id: "is_combinable",
header: () => <TypeHeader text={t("fields.type")} />,
cell: ({ row }) => (
<TypeCell is_combinable={row.original.is_combinable} />
),
}),
columnHelper.display({
id: "code",
header: () => <TextHeader text={t("fields.code")} />,
cell: ({ row }) => <TextCell text={row.original.code || "-"} />,
}),
],
[]
)
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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<string, any>) {
return getRequest<AdminTaxRegionResponse>(`/admin/tax-regions/${id}`, query)
@@ -12,7 +18,42 @@ async function listTaxRegions(query?: Record<string, any>) {
return getRequest<AdminTaxRegionListResponse>(`/admin/tax-regions`, query)
}
async function createTaxRegion(payload: AdminCreateTaxRegion) {
return postRequest<AdminTaxRegionResponse>(`/admin/tax-regions`, payload)
}
async function deleteTaxRegion(id: string) {
return deleteRequest<TaxRegionDeleteRes>(`/admin/tax-regions/${id}`)
}
async function retrieveTaxRate(id: string, query?: Record<string, any>) {
return getRequest<AdminTaxRegionResponse>(`/admin/tax-rates/${id}`, query)
}
async function listTaxRates(query?: Record<string, any>) {
return getRequest<AdminTaxRegionListResponse>(`/admin/tax-rates`, query)
}
async function updateTaxRate(id: string, payload: AdminPostTaxRatesTaxRateReq) {
return postRequest<AdminTaxRateResponse>(`/admin/tax-rates/${id}`, payload)
}
async function createTaxRate(payload: AdminCreateTaxRate) {
return postRequest<AdminTaxRateResponse>(`/admin/tax-rates`, payload)
}
async function deleteTaxRate(id: string) {
return deleteRequest<TaxRateDeleteRes>(`/admin/tax-rates/${id}`)
}
export const taxes = {
retrieveTaxRegion,
listTaxRegions,
retrieveTaxRate,
listTaxRates,
updateTaxRate,
createTaxRegion,
deleteTaxRegion,
createTaxRate,
deleteTaxRate,
}

View File

@@ -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<RegionCountryDTO, "id"> | undefined {
if (!iso2) {
return
}
return countries.find((c) => c.iso_2 === iso2)
}
export const countries: Omit<RegionCountryDTO, "id">[] = [
{
iso_2: "af",

View File

@@ -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
},
},
},
],
},
],
},
],
},
],
},

View File

@@ -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 & {

View File

@@ -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 = () => {
</Button>
</Link>
</div>
<DataTable
table={table}
columns={columns}

View File

@@ -0,0 +1,663 @@
import {
CustomerGroup,
Product,
ProductTag,
ProductType,
} from "@medusajs/medusa"
import { Button } from "@medusajs/ui"
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import { useAdminProductTags } from "medusa-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { SplitView } from "../../../../../components/layout/split-view"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useProductConditionsTableColumns } from "../../hooks/columns/use-product-conditions-table-columns"
import { useCustomerGroupTableQuery } from "../../../../../hooks/table/query/use-customer-group-table-query"
import { useCustomerGroupConditionsTableColumns } from "../../hooks/columns/use-customer-group-conditions-table-columns"
import { useCustomerGroupConditionsTableFilters } from "../../hooks/filters/use-customer-group-conditions-table-filters"
import { useProductTypeConditionsTableColumns } from "../../hooks/columns/use-product-type-conditions-table-columns"
import { useProductTypeConditionsTableFilters } from "../../hooks/filters/use-product-type-conditions-table-filters"
import { useProductTypeConditionsTableQuery } from "../../hooks/query/use-product-type-conditions-table-query"
import { useProductCollectionConditionsTableColumns } from "../../hooks/columns/use-product-collection-conditions-table-columns"
import { useProductCollectionConditionsTableFilters } from "../../hooks/filters/use-product-collection-conditions-table-filters"
import { useProductCollectionConditionsTableQuery } from "../../hooks/query/use-product-collection-conditions-table-query"
import { useProductTagConditionsTableColumns } from "../../hooks/columns/use-product-tag-conditions-table-columns"
import { useProductTagConditionsTableFilters } from "../../hooks/filters/use-product-tag-conditions-table-filters"
import { useProductTagConditionsTableQuery } from "../../hooks/query/use-product-tag-conditions-table-query"
import { ProductCollectionDTO } from "@medusajs/types"
import { useCollections } from "../../../../../hooks/api/collections"
import { useCustomerGroups } from "../../../../../hooks/api/customer-groups"
import { useProductTypes } from "../../../../../hooks/api/product-types"
import { useProducts } from "../../../../../hooks/api/products"
import { ConditionEntities } from "../../constants"
import { ConditionsOption } from "../../types"
const PAGE_SIZE = 50
const PRODUCT_PREFIX = "product"
const PRODUCT_TYPE_PREFIX = "product_type"
const PRODUCT_COLLECTION_PREFIX = "product_collection"
const CUSTOMER_GROUP_PREFIX = "customer_group"
const PRODUCT_TAG_PREFIX = "customer_group"
type ConditionsProps = {
selected: ConditionsOption[]
onSave: (options: ConditionsOption[]) => 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 (
<div className="flex items-center justify-end gap-x-2 border-t p-4">
<SplitView.Close type="button" asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</SplitView.Close>
<Button size="small" type="button" onClick={onSave}>
{t("actions.save")}
</Button>
</div>
)
}
const ProductConditionsTable = ({ selected = [], onSave }: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(selected)
const { searchParams, raw } = useProductTableQuery({
pageSize: PAGE_SIZE,
prefix: PRODUCT_PREFIX,
})
const { products, count, isLoading, isError, error } = useProducts(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const updater: OnChangeFn<RowSelectionState> = (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 (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["title", "created_at", "updated_at"]}
prefix={PRODUCT_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
const CustomerGroupConditionsTable = ({
selected = [],
onSave,
}: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(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<RowSelectionState> = (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 (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["name", "created_at", "updated_at"]}
prefix={CUSTOMER_GROUP_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
const ProductTypeConditionsTable = ({
onSave,
selected = [],
}: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(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<RowSelectionState> = (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 (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["value", "created_at", "updated_at"]}
prefix={PRODUCT_TYPE_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
const ProductCollectionConditionsTable = ({
onSave,
selected = [],
}: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(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<RowSelectionState> = (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 (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isPending}
layout="fill"
orderBy={["title", "handle", "created_at", "updated_at"]}
prefix={PRODUCT_COLLECTION_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
const ProductTagConditionsTable = ({
onSave,
selected = [],
}: ConditionsProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<ConditionsOption[]>(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<RowSelectionState> = (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 (
<div className="flex size-full flex-col overflow-hidden">
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
count={count}
queryObject={raw}
pagination
search
filters={filters}
isLoading={isLoading}
layout="fill"
orderBy={["title", "handle", "created_at", "updated_at"]}
prefix={PRODUCT_TAG_PREFIX}
/>
<ConditionsFooter onSave={handleSave} />
</div>
)
}
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 <ProductConditionsTable {...product} />
case ConditionEntities.PRODUCT_TYPE:
return <ProductTypeConditionsTable {...productType} />
case ConditionEntities.PRODUCT_COLLECTION:
return <ProductCollectionConditionsTable {...productCollection} />
case ConditionEntities.PRODUCT_TAG:
return <ProductTagConditionsTable {...productTag} />
case ConditionEntities.CUSTOMER_GROUP:
return <CustomerGroupConditionsTable {...customerGroup} />
default:
return null
}
}

View File

@@ -0,0 +1 @@
export * from "./conditions-drawer"

View File

@@ -0,0 +1 @@
export * from "./tax-region-create-form"

View File

@@ -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<zod.infer<typeof formSchema>>({
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 (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button
type="submit"
variant="primary"
size="small"
isLoading={isPending}
>
{t("actions.create")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex h-full w-full flex-col items-center overflow-hidden p-16">
<div className="flex flex-col gap-y-8 w-full max-w-[720px]">
<div>
<Heading className="text-left">
{taxRegion
? t("taxRegions.create-child.title")
: t("taxRegions.create.title")}
</Heading>
<Text className="text-ui-fg-subtle txt-small">
{taxRegion
? t("taxRegions.create-child.description")
: t("taxRegions.create.description")}
</Text>
</div>
{!taxRegion && (
<Form.Field
control={form.control}
name="country_code"
render={({ field: { ref, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.country")}</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{countries?.map((country) => (
<Select.Item
key={country.iso_2}
value={country.iso_2}
>
{country.display_name}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
)}
{taxRegion && (
<Form.Field
control={form.control}
name="province_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.province")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
)}
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>Tax Rate Name</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="rate"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.rate")}</Form.Label>
<Form.Control>
<PercentageInput
{...field}
value={field.value}
onChange={(e) => {
if (e.target.value) {
field.onChange(parseInt(e.target.value))
}
}}
/>
</Form.Control>
<Form.Hint className="!mt-1">
{t("taxRegions.fields.rate.hint")}
</Form.Hint>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.code")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
{!taxRegion?.parent_id && (
<Form.Field
control={form.control}
name="is_combinable"
render={({ field: { ref, onChange, value, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("taxRates.fields.isCombinable")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
<Form.Hint className="!mt-1">
{t("taxRegions.fields.is_combinable.hint")}
</Form.Hint>
</Form.Item>
)
}}
/>
)}
</div>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -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",
}

View File

@@ -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<CustomerGroup>()
export const useCustomerGroupConditionsTableColumns = () => {
const base = useCustomerGroupTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
],
[base]
)
}

View File

@@ -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<ProductCollectionDTO>()
export const useProductCollectionConditionsTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
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 <span>{count || "-"}</span>
},
}),
],
[t]
)
}

View File

@@ -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<Product>()
export const useProductConditionsTableColumns = () => {
const base = useProductTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
],
[base]
)
}

View File

@@ -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<ProductTag>()
export const useProductTagConditionsTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
columnHelper.accessor("value", {
header: t("fields.value"),
}),
],
[t]
)
}

View File

@@ -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<ProductType>()
export const useProductTypeConditionsTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
columnHelper.accessor("value", {
header: t("fields.value"),
cell: ({ getValue }) => getValue(),
}),
],
[t]
)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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}`

View File

@@ -0,0 +1,3 @@
export * from "./tax-province-create"
export { TaxProvinceCreate as Component } from "./tax-province-create"

View File

@@ -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 (
<RouteFocusModal>
<TaxRegionCreateForm
taxRegion={taxRegion}
formSchema={CreateTaxProvinceForm}
/>
</RouteFocusModal>
)
}

View File

@@ -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 (
<div className="text-center">
<div className="bg-ui-bg-field shadow-borders-base inline-flex items-center divide-x overflow-hidden rounded-md">
<Text
as="span"
size="small"
leading="compact"
weight="plus"
className="text-ui-fg-muted shrink-0 px-2 py-1"
>
{t("taxRates.fields.appliesTo")} {t(`taxRates.fields.${type}`)}
</Text>
<div className="text-ui-fg-subtle max-w-[240px] shrink-0">
<Button
type="button"
variant="transparent"
size="small"
disabled={isInButtonDisabled}
onClick={() => onClick()}
className="txt-compact-small-plus disabled:text-ui-fg-subtle rounded-none"
>
{labels.length && (
<ListSummary
inline
n={N}
className="!txt-compact-small-plus max-w-[200px]"
list={labels}
/>
)}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1 @@
export * from "./condition"

View File

@@ -0,0 +1,2 @@
export * from "./condition"
export * from "./tax-rate-create-form"

View File

@@ -0,0 +1 @@
export * from "./tax-rate-create-form"

View File

@@ -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<zod.infer<typeof CreateTaxRateSchema>>({
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<ConditionEntities | null>(
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 (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button type="submit" size="small" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex h-full w-full flex-col items-center overflow-hidden">
<SplitView open={open} onOpenChange={handleOpenChange}>
<SplitView.Content>
<div
className={clx("flex flex-col overflow-auto py-16", {
"items-center": !open,
})}
>
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading className="text-left">
{t("taxRates.create.title")}
</Heading>
<Text className="text-ui-fg-subtle txt-small">
{t("taxRates.create.description")}
</Text>
</div>
<Form.Field
control={form.control}
name="tax_region_id"
render={({ field: { ref, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.province")}</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{taxRegions?.map((taxRegion) => (
<Select.Item
key={taxRegion.id}
value={taxRegion.id}
>
{taxRegion.province_code}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="rate"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.rate")}</Form.Label>
<Form.Control>
<PercentageInput
{...field}
value={field.value}
onChange={(e) => {
if (e.target.value) {
field.onChange(parseInt(e.target.value))
}
}}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.code")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
{taxRegion.parent_id && (
<Form.Field
control={form.control}
name="is_combinable"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("taxRates.fields.isCombinable")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</Form.Item>
)
}}
/>
)}
<div className="flex flex-col gap-y-8">
{selectedTypes.length > 0 && (
<div className="flex flex-col items-start gap-y-4">
{selectedTypes.map((selectedType) => {
if (selectedType in (selectedConditionTypes || {})) {
const field = form.getValues(selectedType) || []
return (
<Condition
key={selectedType}
type={selectedType}
labels={field.map((f) => f.label)}
onClick={() => {
setConditionType(selectedType)
setOpen(true)
}}
/>
)
}
})}
</div>
)}
<div className="flex items-center gap-x-2">
<DropdownMenu
open={isDropdownOpen}
onOpenChange={(v) => {
v && setIsDropdownOpen(v)
}}
>
<DropdownMenu.Trigger asChild>
<Button variant="secondary" size="small">
{t("discounts.conditions.manageTypesAction")}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
onInteractOutside={(e) => {
setIsDropdownOpen(true)
}}
>
{Object.values(ConditionEntities).map((type) => (
<DropdownMenu.CheckboxItem
key={type}
checked={selectedConditionTypes[type]}
onCheckedChange={() =>
toggleSelectedConditionTypes(type)
}
>
<Text
size="small"
weight={
selectedConditionTypes[type]
? "plus"
: "regular"
}
>
{t(`fields.${type}`)}
</Text>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Content>
</DropdownMenu>
{selectedTypes.length > 0 && (
<Button
variant="transparent"
size="small"
type="button"
onClick={clearAllSelectedConditions}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
>
{t("actions.clearAll")}
</Button>
)}
</div>
</div>
</div>
</div>
</SplitView.Content>
<SplitView.Drawer>
<ConditionsDrawer
product={{
selected: selectedProducts,
onSave: handleSaveConditions(ConditionEntities.PRODUCT),
}}
productCollection={{
selected: selectedProductCollections,
onSave: handleSaveConditions(
ConditionEntities.PRODUCT_COLLECTION
),
}}
productType={{
selected: selectedProductTypes,
onSave: handleSaveConditions(ConditionEntities.PRODUCT_TYPE),
}}
productTag={{
selected: selectedProductTags,
onSave: handleSaveConditions(ConditionEntities.PRODUCT_TAG),
}}
customerGroup={{
selected: selectedCustomerGroups,
onSave: handleSaveConditions(
ConditionEntities.CUSTOMER_GROUP
),
}}
selected={conditionType}
/>
</SplitView.Drawer>
</SplitView>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1,3 @@
export * from "./tax-rate-create"
export { TaxRateCreate as Component } from "./tax-rate-create"

View File

@@ -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 && (
<RouteFocusModal>
<TaxRateCreateForm taxRegion={taxRegion} />
</RouteFocusModal>
)
)
}

View File

@@ -0,0 +1 @@
export * from "./tax-rate-edit-form"

View File

@@ -0,0 +1 @@
export * from "./tax-rate-edit-form"

View File

@@ -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<zod.infer<typeof UpdateTaxRateSchema>>({
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<ConditionEntities | null>(
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 (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button type="submit" size="small" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex h-full w-full flex-col items-center overflow-hidden">
<SplitView open={open} onOpenChange={handleOpenChange}>
<SplitView.Content>
<div
className={clx("flex flex-col overflow-auto", {
"items-center": !open,
})}
>
<div className="flex w-full max-w-[720px] flex-col gap-y-8 py-16">
<div>
<Heading className="text-left">
{t("taxRates.edit.title")}
</Heading>
<Text className="text-ui-fg-subtle txt-small">
{t("taxRates.edit.description")}
</Text>
</div>
<Form.Field
control={form.control}
name="name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.name")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="rate"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.rate")}</Form.Label>
<Form.Control>
<PercentageInput
{...field}
value={field.value}
onChange={(e) => {
if (e.target.value) {
field.onChange(parseInt(e.target.value))
}
}}
/>
</Form.Control>
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.code")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
</Form.Item>
)
}}
/>
{taxRate.tax_region?.parent_id && (
<Form.Field
control={form.control}
name="is_combinable"
render={({
field: { ref, onChange, value, ...field },
}) => {
return (
<Form.Item>
<Form.Label>
{t("taxRates.fields.isCombinable")}
</Form.Label>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</Form.Item>
)
}}
/>
)}
{!taxRate.is_default && (
<div className="flex flex-col gap-y-8">
{selectedTypes.length > 0 && (
<div className="flex flex-col items-start gap-y-4">
{selectedTypes.map((selectedType) => {
if (
selectedType in (selectedConditionTypes || {})
) {
const field = form.getValues(selectedType) || []
return (
<Condition
key={selectedType}
type={selectedType}
labels={field.map((f) => f.value)}
onClick={() => {
setConditionType(selectedType)
setOpen(true)
}}
/>
)
}
})}
</div>
)}
<div className="flex items-center gap-x-2">
<DropdownMenu
open={isDropdownOpen}
onOpenChange={(v) => {
v && setIsDropdownOpen(v)
}}
>
<DropdownMenu.Trigger asChild>
<Button variant="secondary" size="small">
{t("discounts.conditions.manageTypesAction")}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
onInteractOutside={() => setIsDropdownOpen(false)}
>
{Object.values(ConditionEntities).map((type) => (
<DropdownMenu.CheckboxItem
key={type}
checked={selectedConditionTypes[type]}
onCheckedChange={() =>
toggleSelectedConditionTypes(type)
}
>
<Text
size="small"
weight={
selectedConditionTypes[type]
? "plus"
: "regular"
}
>
{t(`fields.${type}`)}
</Text>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Content>
</DropdownMenu>
{selectedTypes.length > 0 && (
<Button
variant="transparent"
size="small"
type="button"
onClick={clearAllSelectedConditions}
className="text-ui-fg-muted hover:text-ui-fg-subtle"
>
{t("actions.clearAll")}
</Button>
)}
</div>
</div>
)}
</div>
</div>
</SplitView.Content>
<SplitView.Drawer>
<ConditionsDrawer
product={{
selected: selectedProducts,
onSave: handleSaveConditions(ConditionEntities.PRODUCT),
}}
productCollection={{
selected: selectedProductCollections,
onSave: handleSaveConditions(
ConditionEntities.PRODUCT_COLLECTION
),
}}
productType={{
selected: selectedProductTypes,
onSave: handleSaveConditions(ConditionEntities.PRODUCT_TYPE),
}}
productTag={{
selected: selectedProductTags,
onSave: handleSaveConditions(ConditionEntities.PRODUCT_TAG),
}}
customerGroup={{
selected: selectedCustomerGroups,
onSave: handleSaveConditions(
ConditionEntities.CUSTOMER_GROUP
),
}}
selected={conditionType}
/>
</SplitView.Drawer>
</SplitView>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}

View File

@@ -0,0 +1,4 @@
export * from "./tax-rate-edit"
export { taxRateLoader as loader } from "./loader"
export { TaxRateEdit as Component } from "./tax-rate-edit"

View File

@@ -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<AdminTaxRateResponse>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -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 <div>Loading...</div>
}
if (isError) {
throw error
}
return (
taxRegion &&
taxRate && (
<RouteFocusModal>
<TaxRateEditForm taxRegion={taxRegion} taxRate={taxRate} />
</RouteFocusModal>
)
)
}

View File

@@ -0,0 +1,3 @@
export * from "./tax-region-create"
export { TaxRegionCreate as Component } from "./tax-region-create"

View File

@@ -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 (
<RouteFocusModal>
<TaxRegionCreateForm formSchema={CreateTaxRegionForm} />
</RouteFocusModal>
)
}

View File

@@ -0,0 +1,2 @@
export * from "./tax-rate-list"
export * from "./tax-region-general-detail"

View File

@@ -0,0 +1 @@
export * from "./tax-rate-list"

View File

@@ -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<RowSelectionState>({})
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 (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">
{isDefault ? `Default ${t("taxRates.domain")}` : `Tax Rate Overrides`}
</Heading>
<Link
to={
isDefault
? `/settings/taxes/${taxRegion.id}/create-default`
: `/settings/taxes/${taxRegion.id}/create-override`
}
>
<Button size="small" variant="secondary">
Create
</Button>
</Link>
</div>
<DataTable
table={table}
columns={columns}
pageSize={PAGE_SIZE}
commands={[
{
action: handleRemove,
label: t("actions.remove"),
shortcut: "r",
},
]}
count={count}
pagination
search
filters={filters}
navigateTo={(row) =>
`/settings/taxes/${taxRegion.id}/tax-rates/${row.id}/edit`
}
isLoading={isLoading}
orderBy={["is_default", "rate", "created_at", "updated_at"]}
queryObject={raw}
/>
</Container>
)
}
const columnHelper = createColumnHelper<TaxRateResponse>()
const useColumns = () => {
const base = useTaxRateTableColumns()
return useMemo(
() => [
...base,
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
const { taxRegionId } = table.options.meta as {
taxRegionId: string
}
return (
<TaxRateListActions
taxRateId={row.original.id}
taxRegionId={taxRegionId}
/>
)
},
}),
],
[base]
)
}
const TaxRateListActions = ({
taxRateId,
taxRegionId,
}: {
taxRateId: string
taxRegionId: string
}) => {
const { t } = useTranslation()
const { mutateAsync } = useDeleteTaxRate(taxRateId)
const onRemove = async () => await mutateAsync()
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/settings/taxes/${taxRegionId}/tax-rates/${taxRateId}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.remove"),
onClick: onRemove,
},
],
},
]}
/>
)
}

View File

@@ -0,0 +1 @@
export * from "./tax-region-general-detail"

View File

@@ -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 (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<div>
<Heading>{displayName}</Heading>
<Text className="text-ui-fg-subtle" size="small">
{t("taxRegions.description")}
</Text>
</div>
</div>
<div className="grid grid-cols-2 items-center px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("fields.created")}
</Text>
<Text size="small" leading="compact">
{formatDate(taxRegion.created_at)}
</Text>
</div>
</Container>
)
}

View File

@@ -0,0 +1,4 @@
export * from "./tax-region-detail"
export { taxRegionLoader as loader } from "./loader"
export { TaxRegionDetail as Component } from "./tax-region-detail"

View File

@@ -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<AdminTaxRegionResponse>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}

View File

@@ -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 <div>Loading...</div>
}
if (isError) {
throw error
}
return (
taxRegion && (
<div className="flex flex-col gap-y-2">
<TaxRegionGeneralDetail taxRegion={taxRegion} />
<TaxRateList taxRegion={taxRegion} isDefault={true} />
<TaxRateList taxRegion={taxRegion} isDefault={false} />
<JsonViewSection data={taxRegion} root="tax_region" />
<Outlet />
</div>
)
)
}

View File

@@ -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 = () => {
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("taxes.domain")}</Heading>
<Link to="/settings/taxes/create">
<Button size="small" variant="secondary">
{t("actions.create")}
</Button>
</Link>
<Button size="small" variant="secondary" asChild>
<Link to="/settings/taxes/create">{t("actions.create")}</Link>
</Button>
</div>
<DataTable
table={table}
@@ -57,11 +58,8 @@ export const TaxRegionListTable = () => {
count={count}
pageSize={PAGE_SIZE}
isLoading={isLoading}
filters={filters}
orderBy={["name", "created_at", "updated_at"]}
navigateTo={(row) => `${row.original.id}`}
pagination
search
queryObject={raw}
/>
</Container>
@@ -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 (
<ActionMenu
@@ -81,9 +89,9 @@ const TaxRegionActions = ({
{
actions: [
{
label: t("actions.edit"),
to: `/settings/taxes/${taxRegion.id}/edit`,
icon: <PencilSquare />,
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
@@ -97,20 +105,18 @@ const columnHelper = createColumnHelper<AdminTaxRegionResponse["tax_region"]>()
const useColumns = () => {
return useMemo(
() => [
columnHelper.accessor("name", {
header: t("fields.code"),
cell: ({ getValue }) => (
<div className="flex size-full items-center">
<span className="truncate">{getValue()}</span>
</div>
),
}),
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 <DateCell date={date} />
return (
<div className="flex size-full items-center">
<span className="truncate">{displayName}</span>
</div>
)
},
}),
columnHelper.display({

View File

@@ -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<AdminUpdateTaxRateType>,

View File

@@ -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",

View File

@@ -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,
}

View File

@@ -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<AdminCreateTaxRateType>,

View File

@@ -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<typeof AdminGetTaxRateParams>
export const AdminGetTaxRateParams = createSelectParams()
@@ -8,7 +12,20 @@ export type AdminGetTaxRatesParamsType = z.infer<typeof AdminGetTaxRatesParams>
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<typeof AdminCreateTaxRateRule>
export const AdminCreateTaxRateRule = z.object({

View File

@@ -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

View File

@@ -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
),
],
},
]

View File

@@ -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 = {

View File

@@ -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(),

View File

@@ -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<string, string> = {
tax_rate_id: TaxRate.name,

View File

@@ -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<TaxRateRule>(this)

View File

@@ -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

View File

@@ -1 +1,2 @@
export * from "./tax-rates"
export * from "./tax-regions"

View File

@@ -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<string, unknown> | 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[]
}

View File

@@ -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<string, unknown> | 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
}
/**