feat(medusa,dashboard): Tax settings pages and fixes to list tax rates endpoint (#6606)

**What**
- Adds pages for managing tax settings and tax rates for regions.
- Fixes `list` tax rates endpoint, which was missing pagination, search, order, and filters.

**Note**
The fix to the tax rate list endpoint is very rough, as I have had to reimplement most of the logic from our transformQuery middleware. This is because this endpoints does not follow normal convention for fields and expand and uses string arrays instead of strings separated by commas. Our middleware does not support this, and changing the endpoint to align with other endpoints on the expand and fields params would be a breaking change. Since this is very temporary until 2.0 I think it's okay for the time being.

CLOSES CORE-1654
This commit is contained in:
Kasper Fabricius Kristensen
2024-03-09 13:19:29 +00:00
committed by GitHub
parent 7109969874
commit c2d56ca12b
88 changed files with 3659 additions and 489 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@medusajs/client-types": patch
"@medusajs/medusa": patch
---
fix(medusa): Fixes pagination on list Tax Rate endpoint, and also adds missing query params like order, search and filters.
@@ -347,7 +347,7 @@
"removeCountriesWarning_one": "You are about to remove {{count}} country from the region. This action cannot be undone.", "removeCountriesWarning_one": "You are about to remove {{count}} country from the region. This action cannot be undone.",
"removeCountriesWarning_other": "You are about to remove {{count}} countries from the region. This action cannot be undone.", "removeCountriesWarning_other": "You are about to remove {{count}} countries from the region. This action cannot be undone.",
"removeCountryWarning": "You are about to remove the country {{name}} from the region. This action cannot be undone.", "removeCountryWarning": "You are about to remove the country {{name}} from the region. This action cannot be undone.",
"taxInclusiveHint": "When enabled all prices in the region will be tax inclusive.", "taxInclusiveHint": "When enabled prices in the region will be tax inclusive.",
"providersHint": " Add which fulfillment and payment providers should be available in this region.", "providersHint": " Add which fulfillment and payment providers should be available in this region.",
"shippingOptions": "Shipping Options", "shippingOptions": "Shipping Options",
"deleteShippingOptionWarning": "You are about to delete the shipping option {{name}}. This action cannot be undone.", "deleteShippingOptionWarning": "You are about to delete the shipping option {{name}}. This action cannot be undone.",
@@ -382,6 +382,50 @@
} }
} }
}, },
"taxes": {
"domain": "Taxes",
"countries": {
"taxCountriesHint": "Tax settings apply to the listed countries."
},
"settings": {
"editTaxSettings": "Edit Tax Settings",
"taxProviderLabel": "Tax provider",
"systemTaxProviderLabel": "System Tax Provider",
"calculateTaxesAutomaticallyLabel": "Calculate taxes automatically",
"calculateTaxesAutomaticallyHint": "When enabled, tax rates will be calculated automatically and applied to carts. When disabled, taxes must be manually computed at checkout. Manual taxes are recommended for usage with third-party tax providers.",
"applyTaxesOnGiftCardsLabel": "Apply taxes on gift cards",
"applyTaxesOnGiftCardsHint": "When enabled, taxes will be applied to gift cards at checkout. In some countries, tax regulations require the application of taxes to gift cards upon purchase.",
"defaultTaxRateLabel": "Default tax rate",
"defaultTaxCodeLabel": "Default tax code"
},
"defaultRate": {
"sectionTitle": "Default Tax Rate"
},
"taxRate": {
"sectionTitle": "Tax Rates",
"createTaxRate": "Create Tax Rate",
"createTaxRateHint": "Create a new tax rate for the region.",
"deleteRateDescription": "You are about to delete the tax rate {{name}}. This action cannot be undone.",
"editTaxRate": "Edit Tax Rate",
"editRateAction": "Edit rate",
"editOverridesAction": "Edit overrides",
"editOverridesTitle": "Edit Tax Rate Overrides",
"editOverridesHint": "Specify the overrides for the tax rate.",
"deleteTaxRateWarning": "You are about to delete the tax rate {{name}}. This action cannot be undone.",
"productOverridesLabel": "Product overrides",
"productOverridesHint": "Specify the product overrides for the tax rate.",
"addProductOverridesAction": "Add product overrides",
"productTypeOverridesLabel": "Product type overrides",
"productTypeOverridesHint": "Specify the product type overrides for the tax rate.",
"addProductTypeOverridesAction": "Add product type overrides",
"shippingOptionOverridesLabel": "Shipping option overrides",
"shippingOptionOverridesHint": "Specify the shipping option overrides for the tax rate.",
"addShippingOptionOverridesAction": "Add shipping option overrides",
"productOverridesHeader": "Products",
"productTypeOverridesHeader": "Product Types",
"shippingOptionOverridesHeader": "Shipping Options"
}
},
"locations": { "locations": {
"domain": "Locations", "domain": "Locations",
"createLocation": "Create location", "createLocation": "Create location",
@@ -503,7 +547,7 @@
"availability": "Availability", "availability": "Availability",
"inventory": "Inventory", "inventory": "Inventory",
"optional": "Optional", "optional": "Optional",
"taxInclusivePricing": "Tax Inclusive Pricing", "taxInclusivePricing": "Tax inclusive pricing",
"taxRate": "Tax Rate", "taxRate": "Tax Rate",
"taxCode": "Tax Code", "taxCode": "Tax Code",
"currency": "Currency", "currency": "Currency",
@@ -568,9 +612,9 @@
"maxSubtotal": "Max. Subtotal", "maxSubtotal": "Max. Subtotal",
"shippingProfile": "Shipping Profile", "shippingProfile": "Shipping Profile",
"summary": "Summary", "summary": "Summary",
"shippingProfile": "Shipping Profile" "rate": "Rate"
}, },
"dateTime" : { "dateTime": {
"years_one": "Year", "years_one": "Year",
"years_other": "Years", "years_other": "Years",
"months_one": "Month", "months_one": "Month",
@@ -0,0 +1 @@
export * from "./percentage-input"
@@ -0,0 +1,32 @@
import { Input, Text } from "@medusajs/ui"
import { ComponentProps, ElementRef, forwardRef } from "react"
export const PercentageInput = forwardRef<
ElementRef<typeof Input>,
Omit<ComponentProps<typeof Input>, "type">
>(({ min = 0, max = 100, step = 0.0001, ...props }, ref) => {
return (
<div className="relative">
<div className="absolute inset-y-0 left-0 z-10 flex w-8 items-center justify-center border-r">
<Text
className="text-ui-fg-muted"
size="small"
leading="compact"
weight="plus"
>
%
</Text>
</div>
<Input
ref={ref}
type="number"
min={min}
max={max}
step={step}
{...props}
className="pl-10"
/>
</div>
)
})
PercentageInput.displayName = "HandleInput"
@@ -109,7 +109,7 @@ export const DateFilter = ({
sideOffset={8} sideOffset={8}
collisionPadding={24} collisionPadding={24}
className={clx( className={clx(
"bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout max-h-[var(--radix-popper-available-height)] w-[300px] overflow-hidden rounded-lg" "bg-ui-bg-base text-ui-fg-base shadow-elevation-flyout h-full max-h-[var(--radix-popper-available-height)] w-[300px] overflow-auto rounded-lg"
)} )}
onInteractOutside={(e) => { onInteractOutside={(e) => {
if (e.target instanceof HTMLElement) { if (e.target instanceof HTMLElement) {
@@ -9,6 +9,7 @@ type DataTableSkeletonProps = {
orderBy: boolean orderBy: boolean
filterable: boolean filterable: boolean
pagination: boolean pagination: boolean
layout?: "fit" | "fill"
} }
export const DataTableSkeleton = ({ export const DataTableSkeleton = ({
@@ -18,6 +19,7 @@ export const DataTableSkeleton = ({
searchable, searchable,
orderBy, orderBy,
pagination, pagination,
layout = "fit",
}: DataTableSkeletonProps) => { }: DataTableSkeletonProps) => {
const rows = Array.from({ length: rowCount }, (_, i) => i) const rows = Array.from({ length: rowCount }, (_, i) => i)
@@ -30,7 +32,11 @@ export const DataTableSkeleton = ({
const colWidth = 100 / colCount const colWidth = 100 / colCount
return ( return (
<div> <div
className={clx({
"flex h-full flex-col overflow-hidden": layout === "fill",
})}
>
{hasToolbar && ( {hasToolbar && (
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">
{filterable && <Skeleton className="h-7 w-full max-w-[160px]" />} {filterable && <Skeleton className="h-7 w-full max-w-[160px]" />}
@@ -42,74 +48,91 @@ export const DataTableSkeleton = ({
)} )}
</div> </div>
)} )}
<Table> <div
<Table.Header> className={clx("flex w-full flex-col overflow-hidden", {
<Table.Row "flex flex-1 flex-col": layout === "fill",
className={clx({ })}
"border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap": >
hasActions, <div
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap": className={clx("w-full", {
hasSelect, "min-h-0 flex-grow overflow-hidden": layout === "fill",
"overflow-x-auto": layout === "fit",
})}
>
<Table>
<Table.Header>
<Table.Row
className={clx({
"border-b-0 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
hasActions,
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
hasSelect,
})}
>
{columns.map((col, i) => {
const isSelectHeader = col.id === "select"
const isActionsHeader = col.id === "actions"
const isSpecialHeader = isSelectHeader || isActionsHeader
return (
<Table.HeaderCell
key={i}
style={{
width: !isSpecialHeader ? `${colWidth}%` : undefined,
}}
>
{isActionsHeader ? null : (
<Skeleton
className={clx("h-7", {
"w-7": isSelectHeader,
"w-full": !isSelectHeader,
})}
/>
)}
</Table.HeaderCell>
)
})}
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((_, j) => (
<Table.Row key={j}>
{columns.map((col, k) => {
const isSpecialCell =
col.id === "select" || col.id === "actions"
return (
<Table.Cell key={k}>
<Skeleton
className={clx("h-7", {
"w-7": isSpecialCell,
"w-full": !isSpecialCell,
})}
/>
</Table.Cell>
)
})}
</Table.Row>
))}
</Table.Body>
</Table>
</div>
{pagination && (
<div
className={clx("flex items-center justify-between p-4", {
"border-t": layout === "fill",
})} })}
> >
{columns.map((col, i) => { <Skeleton className="h-7 w-[138px]" />
const isSelectHeader = col.id === "select" <div className="flex items-center gap-x-2">
const isActionsHeader = col.id === "actions" <Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-11" />
const isSpecialHeader = isSelectHeader || isActionsHeader <Skeleton className="h-7 w-11" />
</div>
return (
<Table.HeaderCell
key={i}
style={{
width: !isSpecialHeader ? `${colWidth}%` : undefined,
}}
>
{isActionsHeader ? null : (
<Skeleton
className={clx("h-7", {
"w-7": isSelectHeader,
"w-full": !isSelectHeader,
})}
/>
)}
</Table.HeaderCell>
)
})}
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((_, j) => (
<Table.Row key={j}>
{columns.map((col, k) => {
const isSpecialCell =
col.id === "select" || col.id === "actions"
return (
<Table.Cell key={k}>
<Skeleton
className={clx("h-7", {
"w-7": isSpecialCell,
"w-full": !isSpecialCell,
})}
/>
</Table.Cell>
)
})}
</Table.Row>
))}
</Table.Body>
</Table>
{pagination && (
<div className="flex items-center justify-between p-4">
<Skeleton className="h-7 w-[138px]" />
<div className="flex items-center gap-x-2">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-11" />
<Skeleton className="h-7 w-11" />
</div> </div>
</div> )}
)} </div>
</div> </div>
) )
} }
@@ -36,6 +36,7 @@ export const DataTable = <TData,>({
if (isLoading) { if (isLoading) {
return ( return (
<DataTableSkeleton <DataTableSkeleton
layout={layout}
columns={columns} columns={columns}
rowCount={pageSize} rowCount={pageSize}
searchable={search} searchable={search}
@@ -52,7 +53,13 @@ export const DataTable = <TData,>({
const noRecords = !isLoading && count === 0 && noQuery const noRecords = !isLoading && count === 0 && noQuery
if (noRecords) { if (noRecords) {
return <NoRecords /> return (
<NoRecords
className={clx({
"flex h-full flex-col overflow-hidden": layout === "fill",
})}
/>
)
} }
return ( return (
@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next"
import { StatusCell } from "../../common/status-cell"
type AdminOnlyCellProps = {
adminOnly: boolean
}
export const AdminOnlyCell = ({ adminOnly }: AdminOnlyCellProps) => {
const { t } = useTranslation()
const color = adminOnly ? "blue" : "green"
const text = adminOnly ? t("general.admin") : t("general.store")
return <StatusCell color={color}>{text}</StatusCell>
}
export const AdminOnlyHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("fields.availability")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./admin-only-cell"
@@ -0,0 +1 @@
export * from "./is-return-cell"
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next"
type IsReturnCellProps = {
isReturn?: boolean
}
export const IsReturnCell = ({ isReturn }: IsReturnCellProps) => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">
{isReturn ? t("regions.return") : t("regions.outbound")}
</span>
</div>
)
}
export const IsReturnHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("fields.type")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./price-type-cell"
@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type PriceTypeCellProps = {
priceType?: "flat_rate" | "calculated"
}
export const PriceTypeCell = ({ priceType }: PriceTypeCellProps) => {
const { t } = useTranslation()
if (!priceType) {
return <PlaceholderCell />
}
const isFlatRate = priceType === "flat_rate"
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">
{isFlatRate ? t("regions.flatRate") : t("regions.calculated")}
</span>
</div>
)
}
export const PriceTypeHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("regions.priceType")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./shipping-option-cell"
@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../common/placeholder-cell"
type ShippingOptionCellProps = {
name?: string | null
}
export const ShippingOptionCell = ({ name }: ShippingOptionCellProps) => {
if (!name) {
return <PlaceholderCell />
}
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{name}</span>
</div>
)
}
export const ShippingOptionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("fields.name")}</span>
</div>
)
}
@@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next"
import { MoneyAmountCell } from "../../common/money-amount-cell"
import { PlaceholderCell } from "../../common/placeholder-cell"
type ShippingPriceCellProps = {
isCalculated: boolean
price?: number | null
currencyCode: string
}
export const ShippingPriceCell = ({
price,
currencyCode,
isCalculated,
}: ShippingPriceCellProps) => {
if (isCalculated || !price) {
return <PlaceholderCell />
}
return <MoneyAmountCell currencyCode={currencyCode} amount={price} />
}
export const ShippingPriceHeader = () => {
const { t } = useTranslation()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{t("fields.price")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./subtotal-requirement-cell"
@@ -0,0 +1,43 @@
import { ShippingOption } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
import { MoneyAmountCell } from "../../common/money-amount-cell"
import { PlaceholderCell } from "../../common/placeholder-cell"
type SubtotalType = "min" | "max"
type SubtotalRequirementCellProps = {
type: SubtotalType
shippingOption: ShippingOption
}
export const SubtotalRequirementCell = (
props: SubtotalRequirementCellProps
) => {
const requirement = props.shippingOption.requirements?.find(
(r) => r.type === `${props.type}_subtotal`
)
if (!requirement) {
return <PlaceholderCell />
}
return (
<MoneyAmountCell
currencyCode={props.shippingOption.region!.currency_code}
amount={requirement.amount}
/>
)
}
export const SubtotalRequirementHeader = ({ type }: { type: SubtotalType }) => {
const { t } = useTranslation()
const header =
type === "min" ? t("fields.minSubtotal") : t("fields.maxSubtotal")
return (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{header}</span>
</div>
)
}
@@ -0,0 +1,107 @@
import { ShippingOption } from "@medusajs/medusa"
import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import {
AdminOnlyCell,
AdminOnlyHeader,
} from "../../../components/table/table-cells/shipping-option/admin-only-cell"
import {
IsReturnCell,
IsReturnHeader,
} from "../../../components/table/table-cells/shipping-option/is-return-cell"
import {
PriceTypeCell,
PriceTypeHeader,
} from "../../../components/table/table-cells/shipping-option/price-type-cell"
import {
ShippingOptionCell,
ShippingOptionHeader,
} from "../../../components/table/table-cells/shipping-option/shipping-option-cell"
import {
ShippingPriceCell,
ShippingPriceHeader,
} from "../../../components/table/table-cells/shipping-option/shipping-price-cell/shipping-price-cell"
import {
SubtotalRequirementCell,
SubtotalRequirementHeader,
} from "../../../components/table/table-cells/shipping-option/subtotal-requirement-cell"
const columnHelper = createColumnHelper<PricedShippingOption>()
export const useShippingOptionTableColumns = () => {
return useMemo(
() => [
columnHelper.accessor("name", {
header: () => <ShippingOptionHeader />,
cell: ({ getValue }) => <ShippingOptionCell name={getValue()} />,
}),
columnHelper.accessor("is_return", {
header: () => <IsReturnHeader />,
cell: (cell) => {
const value = cell.getValue()
return <IsReturnCell isReturn={value} />
},
}),
columnHelper.accessor("price_type", {
header: () => <PriceTypeHeader />,
cell: ({ getValue }) => {
const type = getValue()
return <PriceTypeCell priceType={type} />
},
}),
columnHelper.accessor("price_incl_tax", {
header: () => <ShippingPriceHeader />,
cell: ({ getValue, row }) => {
const isCalculated = row.original.price_type === "calculated"
const amount = getValue()
const currencyCode = row.original.region!.currency_code
return (
<ShippingPriceCell
isCalculated={isCalculated}
currencyCode={currencyCode}
price={amount}
/>
)
},
}),
columnHelper.display({
id: "min_amount",
header: () => <SubtotalRequirementHeader type="min" />,
cell: ({ row }) => {
return (
<SubtotalRequirementCell
type="min"
shippingOption={row.original as unknown as ShippingOption}
/>
)
},
}),
columnHelper.display({
id: "max_amount",
header: () => <SubtotalRequirementHeader type="max" />,
cell: ({ row }) => {
return (
<SubtotalRequirementCell
type="max"
shippingOption={row.original as unknown as ShippingOption}
/>
)
},
}),
columnHelper.accessor("admin_only", {
header: () => <AdminOnlyHeader />,
cell: (cell) => {
const value = cell.getValue() || false
return <AdminOnlyCell adminOnly={value} />
},
}),
],
[]
)
}
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table" import { Filter } from "../../../components/table/data-table"
export const useShippingOptionTableFilters = () => { export const useShippingOptionTableFilters = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -1,5 +1,5 @@
import { AdminGetShippingOptionsParams } from "@medusajs/medusa" import { AdminGetShippingOptionsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params" import { useQueryParams } from "../../use-query-params"
type UseShippingOptionTableQueryProps = { type UseShippingOptionTableQueryProps = {
regionId: string regionId: string
@@ -0,0 +1,27 @@
const formatter = new Intl.NumberFormat(undefined, {
style: "percent",
minimumFractionDigits: 2,
})
/**
* Formats a number as a percentage
* @param value - The value to format
* @param isPercentageValue - Whether the value is already a percentage value (where `0` is 0%, `0.5` is 50%, `0.75` is 75%, etc). Defaults to false
* @returns The formatted percentage in the form of a localized string
*
* @example
* formatPercentage(0.5, true) // "50%"
* formatPercentage(50) // "50%"
*/
export const formatPercentage = (
value?: number | null,
isPercentageValue = false
) => {
let val = value || 0
if (!isPercentageValue) {
val = val / 100
}
return formatter.format(val)
}
@@ -559,12 +559,34 @@ const router = createBrowserRouter([
}, },
children: [ children: [
{ {
index: true, path: "",
lazy: () => import("../../routes/taxes/views/tax-list"), lazy: () => import("../../routes/taxes/tax-list"),
}, },
{ {
path: ":id", path: ":id",
lazy: () => import("../../routes/taxes/views/tax-details"), lazy: () => import("../../routes/taxes/tax-detail"),
handle: {
crumb: (data: AdminRegionsRes) => data.region.name,
},
children: [
{
path: "edit",
lazy: () => import("../../routes/taxes/tax-edit"),
},
{
path: "tax-rates/create",
lazy: () => import("../../routes/taxes/tax-rate-create"),
},
{
path: "tax-rates/:rate_id/edit",
lazy: () => import("../../routes/taxes/tax-rate-edit"),
},
{
path: "tax-rates/:rate_id/edit-overrides",
lazy: () =>
import("../../routes/taxes/tax-rate-edit-overrides"),
},
],
}, },
], ],
}, },
@@ -62,8 +62,11 @@ export const EditProductForm = ({ product }: EditProductFormProps) => {
return ( return (
<RouteDrawer.Form form={form}> <RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex h-full flex-col"> <form
<RouteDrawer.Body> onSubmit={handleSubmit}
className="flex flex-1 flex-col overflow-hidden"
>
<RouteDrawer.Body className="flex max-w-full flex-1 flex-col gap-y-8 overflow-y-auto">
<div className="flex h-full flex-col gap-y-8"> <div className="flex h-full flex-col gap-y-8">
<div className="flex flex-col gap-y-4"> <div className="flex flex-col gap-y-4">
<Form.Field <Form.Field
@@ -1,16 +1,21 @@
import { Region } from "@medusajs/medusa" import { Region } from "@medusajs/medusa"
import { Container, Heading } from "@medusajs/ui" import { Container, Heading, usePrompt } from "@medusajs/ui"
import { useAdminShippingOptions } from "medusa-react" import {
useAdminDeleteShippingOption,
useAdminShippingOptions,
} from "medusa-react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { PlusMini } from "@medusajs/icons" import { PencilSquare, PlusMini, Trash } from "@medusajs/icons"
import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing" import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import { useShippingOptionTableColumns } from "../../../../../hooks/table/columns/use-shipping-option-table-columns"
import { useShippingOptionTableFilters } from "../../../../../hooks/table/filters/use-shipping-option-table-filters"
import { useShippingOptionTableQuery } from "../../../../../hooks/table/query/use-shipping-option-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { useShippingOptionColumns } from "./use-shipping-option-table-columns"
import { useShippingOptionTableFilters } from "./use-shipping-option-table-filters"
import { useShippingOptionTableQuery } from "./use-shipping-option-table-query"
type RegionShippingOptionSectionProps = { type RegionShippingOptionSectionProps = {
region: Region region: Region
@@ -36,7 +41,7 @@ export const RegionShippingOptionSection = ({
) )
const filters = useShippingOptionTableFilters() const filters = useShippingOptionTableFilters()
const columns = useShippingOptionColumns() const columns = useColumns()
const { table } = useDataTable({ const { table } = useDataTable({
data: (shipping_options ?? []) as unknown as PricedShippingOption[], data: (shipping_options ?? []) as unknown as PricedShippingOption[],
@@ -94,3 +99,75 @@ export const RegionShippingOptionSection = ({
</Container> </Container>
) )
} }
const ShippingOptionActions = ({
shippingOption,
}: {
shippingOption: PricedShippingOption
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteShippingOption(shippingOption.id!)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("regions.deleteShippingOptionWarning", {
name: shippingOption.name,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `shipping-options/${shippingOption.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<PricedShippingOption>()
const useColumns = () => {
const base = useShippingOptionTableColumns()
return useMemo(
() => [
...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <ShippingOptionActions shippingOption={row.original} />
},
}),
],
[base]
)
}
@@ -1,179 +0,0 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing"
import { usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteShippingOption } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { MoneyAmountCell } from "../../../../../components/table/table-cells/common/money-amount-cell"
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
import { StatusCell } from "../../../../../components/table/table-cells/common/status-cell"
const columnHelper = createColumnHelper<PricedShippingOption>()
export const useShippingOptionColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{getValue()}</span>
</div>
),
}),
columnHelper.accessor("is_return", {
header: t("fields.type"),
cell: (cell) => {
const value = cell.getValue()
return value ? t("regions.return") : t("regions.outbound")
},
}),
columnHelper.accessor("price_type", {
header: t("regions.priceType"),
cell: ({ getValue }) => {
const type = getValue()
return type === "flat_rate"
? t("regions.flatRate")
: t("regions.calculated")
},
}),
columnHelper.accessor("price_incl_tax", {
header: t("fields.price"),
cell: ({ getValue, row }) => {
const isCalculated = row.original.price_type === "calculated"
if (isCalculated) {
return <PlaceholderCell />
}
const amount = getValue()
const currencyCode = row.original.region!.currency_code
return <MoneyAmountCell currencyCode={currencyCode} amount={amount} />
},
}),
columnHelper.display({
id: "min_amount",
header: () => (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{t("fields.minSubtotal")}</span>
</div>
),
cell: ({ row }) => {
const minAmountReq = row.original.requirements?.find(
(r) => r.type === "min_subtotal"
)
if (!minAmountReq) {
return <PlaceholderCell />
}
const amount = minAmountReq.amount
const currencyCode = row.original.region!.currency_code
return <MoneyAmountCell currencyCode={currencyCode} amount={amount} />
},
}),
columnHelper.display({
id: "max_amount",
header: () => (
<div className="flex size-full items-center overflow-hidden">
<span className="truncate">{t("fields.maxSubtotal")}</span>
</div>
),
cell: ({ row }) => {
const maxAmountReq = row.original.requirements?.find(
(r) => r.type === "max_subtotal"
)
if (!maxAmountReq) {
return <PlaceholderCell />
}
const amount = maxAmountReq.amount
const currencyCode = row.original.region!.currency_code
return <MoneyAmountCell currencyCode={currencyCode} amount={amount} />
},
}),
columnHelper.accessor("admin_only", {
header: t("fields.availability"),
cell: (cell) => {
const value = cell.getValue()
return (
<StatusCell color={value ? "blue" : "green"}>
{value ? t("general.admin") : t("general.store")}
</StatusCell>
)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <ShippingOptionActions shippingOption={row.original} />
},
}),
],
[t]
)
}
const ShippingOptionActions = ({
shippingOption,
}: {
shippingOption: PricedShippingOption
}) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteShippingOption(shippingOption.id!)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("regions.deleteShippingOptionWarning", {
name: shippingOption.name,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: `shipping-options/${shippingOption.id}/edit`,
icon: <PencilSquare />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
onClick: handleDelete,
icon: <Trash />,
},
],
},
]}
/>
)
}
@@ -1,4 +1,4 @@
import { AdminProductsRes } from "@medusajs/medusa" import { AdminRegionsRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js" import { Response } from "@medusajs/medusa-js"
import { adminRegionKeys } from "medusa-react" import { adminRegionKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom" import { LoaderFunctionArgs } from "react-router-dom"
@@ -15,7 +15,7 @@ export const regionLoader = async ({ params }: LoaderFunctionArgs) => {
const query = regionQuery(id!) const query = regionQuery(id!)
return ( return (
queryClient.getQueryData<Response<AdminProductsRes>>(query.queryKey) ?? queryClient.getQueryData<Response<AdminRegionsRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query)) (await queryClient.fetchQuery(query))
) )
} }
@@ -1,14 +1,21 @@
import { useAdminRegion } from "medusa-react" import { useAdminRegion } from "medusa-react"
import { Outlet, useParams } from "react-router-dom" import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section" import { JsonViewSection } from "../../../components/common/json-view-section"
import { RegionCountrySection } from "./components/region-country-section" import { RegionCountrySection } from "./components/region-country-section"
import { RegionGeneralSection } from "./components/region-general-section" import { RegionGeneralSection } from "./components/region-general-section"
import { RegionShippingOptionSection } from "./components/region-shipping-option-section" import { RegionShippingOptionSection } from "./components/region-shipping-option-section"
import { regionLoader } from "./loader"
export const RegionDetail = () => { export const RegionDetail = () => {
const initialData = useLoaderData() as Awaited<
ReturnType<typeof regionLoader>
>
const { id } = useParams() const { id } = useParams()
const { region, isLoading, isError, error } = useAdminRegion(id!) const { region, isLoading, isError, error } = useAdminRegion(id!, {
initialData,
})
// TODO: Move to loading.tsx and set as Suspense fallback for the route // TODO: Move to loading.tsx and set as Suspense fallback for the route
if (isLoading || !region) { if (isLoading || !region) {
@@ -0,0 +1 @@
export * from "./override-chip"
@@ -0,0 +1,24 @@
import { XMarkMini } from "@medusajs/icons"
import { OverrideOption } from "../../types"
type OverrideChipProps = {
override: OverrideOption
onRemove: (value: string) => void
}
export const OverrideChip = ({ override, onRemove }: OverrideChipProps) => {
return (
<div className="bg-ui-bg-field shadow-borders-base transition-fg hover:bg-ui-bg-field-hover flex h-7 items-center overflow-hidden rounded-md">
<div className="txt-compact-small-plus flex h-full select-none items-center justify-center px-2 py-0.5">
{override.label}
</div>
<button
type="button"
onClick={() => onRemove(override.value)}
className="focus-visible:bg-ui-bg-field-hover transition-fg hover:bg-ui-bg-field-hover flex h-full w-7 items-center justify-center border-l outline-none"
>
<XMarkMini className="text-ui-fg-muted" />
</button>
</div>
)
}
@@ -0,0 +1 @@
export * from "./override-grid"
@@ -0,0 +1,42 @@
import { Button } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { OverrideOption } from "../../types"
import { OverrideChip } from "../override-chip"
type OverrideGridProps = {
overrides: OverrideOption[]
onRemove: (value: string) => void
onClear: () => void
}
export const OverrideGrid = ({
overrides,
onRemove,
onClear,
}: OverrideGridProps) => {
const { t } = useTranslation()
if (!overrides.length) {
return null
}
return (
<div className="flex flex-wrap gap-2">
{overrides.map((override) => (
<OverrideChip
key={override.value}
override={override}
onRemove={onRemove}
/>
))}
<Button
variant="transparent"
size="small"
className="text-ui-fg-muted hover:text-ui-fg-subtle"
onClick={onClear}
>
{t("actions.clearAll")}
</Button>
</div>
)
}
@@ -0,0 +1 @@
export * from "./overrides-drawer"
@@ -0,0 +1,394 @@
import { Product } from "@medusajs/medusa"
import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing"
import { Button } from "@medusajs/ui"
import { OnChangeFn, RowSelectionState } from "@tanstack/react-table"
import {
useAdminProductTypes,
useAdminProducts,
useAdminShippingOptions,
} 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 { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useShippingOptionTableFilters } from "../../../../../hooks/table/filters/use-shipping-option-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useShippingOptionTableQuery } from "../../../../../hooks/table/query/use-shipping-option-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { Override } from "../../constants"
import { useProductOverrideTableColumns } from "../../hooks/columns/use-product-override-table-columns"
import { useProductTypeOverrideTableColumns } from "../../hooks/columns/use-product-type-override-table-columns"
import { useShippingOptionOverrideTableColumns } from "../../hooks/columns/use-shipping-option-override-table-columns"
import { useProductTypeOverrideTableFilters } from "../../hooks/filters/use-product-type-override-table-filters"
import { useProductTypeOverrideTableQuery } from "../../hooks/query/use-product-type-override-table-query"
import { OverrideOption } from "../../types"
const PAGE_SIZE = 50
const PRODUCT_PREFIX = "product"
const PRODUCT_TYPE_PREFIX = "product_type"
const SHIPPING_OPTION_PREFIX = "shipping_option"
type OverrideProps = {
selected: OverrideOption[]
onSave: (options: OverrideOption[]) => void
}
const initRowState = (selected: OverrideOption[] = []): RowSelectionState => {
return selected.reduce((acc, { value }) => {
acc[value] = true
return acc
}, {} as RowSelectionState)
}
const OverrideFooter = ({ 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 ProductOverrideTable = ({ selected = [], onSave }: OverrideProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<OverrideOption[]>(selected)
const { searchParams, raw } = useProductTableQuery({
pageSize: PAGE_SIZE,
prefix: PRODUCT_PREFIX,
})
const { products, count, isLoading, isError, error } = useAdminProducts(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
const diff = Object.keys(newState).filter(
(k) => newState[k] !== rowSelection[k]
)
if (diff.length === 0) {
return
}
const addedProducts = (products?.filter((p) => diff.includes(p.id!)) ??
[]) as Product[]
if (addedProducts.length > 0) {
const newOverrides = addedProducts.map((p) => ({
label: p.title,
value: p.id!,
}))
setIntermediate((prev) => {
const filteredPrev = prev.filter((p) =>
Object.keys(newState).includes(p.value)
)
const update = Array.from(new Set([...filteredPrev, ...newOverrides]))
return update
})
}
setRowSelection(newState)
}
const handleSave = () => {
onSave(intermediate)
}
const columns = useProductOverrideTableColumns()
const filters = useProductTableFilters()
const { table } = useDataTable({
data: (products ?? []) as Product[],
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}
/>
<OverrideFooter onSave={handleSave} />
</div>
)
}
const ProductTypeOverrideTable = ({ onSave, selected = [] }: OverrideProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<OverrideOption[]>(selected)
const { searchParams, raw } = useProductTypeOverrideTableQuery({
pageSize: PAGE_SIZE,
prefix: PRODUCT_TYPE_PREFIX,
})
const { product_types, count, isLoading, isError, error } =
useAdminProductTypes(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
const diff = Object.keys(newState).filter(
(k) => newState[k] !== rowSelection[k]
)
if (diff.length === 0) {
return
}
const addedProductTypes =
product_types?.filter((p) => diff.includes(p.id!)) ?? []
if (addedProductTypes.length > 0) {
const newOverrides = addedProductTypes.map((p) => ({
label: p.value,
value: p.id!,
}))
setIntermediate((prev) => {
const filteredPrev = prev.filter((p) =>
Object.keys(newState).includes(p.value)
)
const update = Array.from(new Set([...filteredPrev, ...newOverrides]))
return update
})
}
setRowSelection(newState)
}
const handleSave = () => {
onSave(intermediate)
}
const columns = useProductTypeOverrideTableColumns()
const filters = useProductTypeOverrideTableFilters()
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_PREFIX}
/>
<OverrideFooter onSave={handleSave} />
</div>
)
}
const ShippingOptionOverrideTable = ({
onSave,
selected = [],
regionId,
}: OverrideProps & { regionId: string }) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>(
initRowState(selected)
)
const [intermediate, setIntermediate] = useState<OverrideOption[]>(selected)
const { searchParams, raw } = useShippingOptionTableQuery({
regionId,
pageSize: PAGE_SIZE,
prefix: SHIPPING_OPTION_PREFIX,
})
const { shipping_options, count, isLoading, isError, error } =
useAdminShippingOptions(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const newState: RowSelectionState =
typeof fn === "function" ? fn(rowSelection) : fn
const diff = Object.keys(newState).filter(
(k) => newState[k] !== rowSelection[k]
)
if (diff.length === 0) {
return
}
const addedShippingOptions =
shipping_options?.filter((p) => diff.includes(p.id!)) ?? []
if (addedShippingOptions.length > 0) {
const newOverrides = addedShippingOptions.map((p) => ({
label: p.name,
value: p.id!,
}))
setIntermediate((prev) => {
const filteredPrev = prev.filter((p) =>
Object.keys(newState).includes(p.value)
)
const update = Array.from(new Set([...filteredPrev, ...newOverrides]))
return update
})
}
setRowSelection(newState)
}
const handleSave = () => {
onSave(intermediate)
}
const columns = useShippingOptionOverrideTableColumns()
const filters = useShippingOptionTableFilters()
const { table } = useDataTable({
data: (shipping_options ?? []) as unknown as PricedShippingOption[],
columns: columns,
count,
enablePagination: true,
getRowId: (row) => row.id!,
pageSize: PAGE_SIZE,
enableRowSelection: true,
rowSelection: {
state: rowSelection,
updater,
},
prefix: SHIPPING_OPTION_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={SHIPPING_OPTION_PREFIX}
/>
<OverrideFooter onSave={handleSave} />
</div>
)
}
type OverrideTableProps = {
product: OverrideProps
productType: OverrideProps
shippingOption: OverrideProps
regionId: string
selected: Override | null
}
export const OverrideTable = ({
product,
productType,
shippingOption,
regionId,
selected,
}: OverrideTableProps) => {
switch (selected) {
case Override.PRODUCT:
return <ProductOverrideTable {...product} />
case Override.PRODUCT_TYPE:
return <ProductTypeOverrideTable {...productType} />
case Override.SHIPPING_OPTION:
return (
<ShippingOptionOverrideTable {...shippingOption} regionId={regionId} />
)
default:
return null
}
}
@@ -0,0 +1,5 @@
export enum Override {
PRODUCT = "product",
PRODUCT_TYPE = "product_type",
SHIPPING_OPTION = "shipping_option",
}
@@ -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 useProductOverrideTableColumns = () => {
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]
)
}
@@ -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 useProductTypeOverrideTableColumns = () => {
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]
)
}
@@ -0,0 +1,46 @@
import { PricedShippingOption } from "@medusajs/medusa/dist/types/pricing"
import { Checkbox } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useShippingOptionTableColumns } from "../../../../../hooks/table/columns/use-shipping-option-table-columns"
const columnHelper = createColumnHelper<PricedShippingOption>()
export const useShippingOptionOverrideTableColumns = () => {
const base = useShippingOptionTableColumns()
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]
)
}
@@ -0,0 +1,17 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../../../components/table/data-table"
export const useProductTypeOverrideTableFilters = () => {
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
}
@@ -0,0 +1,29 @@
import { AdminGetProductTypesParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useProductTypeOverrideTableQuery = ({
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,
}
}
@@ -0,0 +1,10 @@
import { Override } from "./constants"
export type OverrideOption = {
value: string
label: string
}
export type OverrideState = {
[K in Override]: boolean
}
@@ -1 +0,0 @@
export * from "./tax-details-section"
@@ -1,85 +0,0 @@
import { Region } from "@medusajs/medusa"
import { Container, Heading, StatusBadge, Text, Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
type Props = {
region: Region
}
export const TaxDetailsSection = ({ region }: Props) => {
const { t } = useTranslation()
return (
<Container className="divide-ui-border-base divide-y p-0">
<div className="px-8 py-6">
<Heading>{region.name}</Heading>
<div className="text-ui-fg-subtle flex items-center gap-x-2">
<Text leading="compact" size="small">
{region.countries
.slice(0, 2)
.map((c) => c.display_name)
.join(", ")}
</Text>
{region.countries.length > 2 && (
<Tooltip
content={
<ul>
{region.countries.slice(2).map((c) => (
<li key={c.id}>{c.display_name}</li>
))}
</ul>
}
>
<Text
leading="compact"
size="small"
weight="plus"
className="cursor-default"
>
{t("general.plusCountMore", {
count: region.countries.length - 2,
})}
</Text>
</Tooltip>
)}
</div>
</div>
<div className="grid grid-cols-2 px-8 py-6">
<Text size="small" weight="plus" leading="compact">
Tax Provider
</Text>
<Text size="small" leading="compact">
{region.tax_provider_id
? region.tax_provider_id
: "System Tax Provider"}
</Text>
</div>
<div className="grid grid-cols-2 px-8 py-6">
<Text size="small" weight="plus" leading="compact">
Automatic Tax Calculation
</Text>
<StatusBadge
color={region.automatic_taxes ? "green" : "green"}
className="w-fit"
>
{region.automatic_taxes
? t("general.enabled")
: t("general.disabled")}
</StatusBadge>
</div>
<div className="grid grid-cols-2 px-8 py-6">
<Text size="small" weight="plus" leading="compact">
Gift Cards Taxable
</Text>
<StatusBadge
color={region.gift_cards_taxable ? "green" : "grey"}
className="w-fit"
>
{region.gift_cards_taxable
? t("general.enabled")
: t("general.disabled")}
</StatusBadge>
</div>
</Container>
)
}
@@ -1,64 +0,0 @@
import { TaxRate } from "@medusajs/medusa"
import { Button, Container, Heading } from "@medusajs/ui"
import {
createColumnHelper,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table"
import { useAdminTaxRates } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { DebouncedSearch } from "../../../../components/common/debounced-search"
export const TaxRatesSection = () => {
const { id } = useParams()
const [query, setQuery] = useState("")
const { tax_rates, isLoading } = useAdminTaxRates(
{
region_id: id,
},
{
enabled: !!id,
}
)
const columns = useColumns()
const table = useReactTable({
data: tax_rates ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-8 pb-4 pt-6">
<Heading level="h2">Tax Rates</Heading>
<div className="flex items-center gap-x-2">
<DebouncedSearch size="small" value={query} onChange={setQuery} />
<Button variant="secondary">Add Tax Rate</Button>
</div>
</div>
</Container>
)
}
const columnHelper = createColumnHelper<TaxRate>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => {
return <span>{getValue()}</span>
},
}),
],
[t]
)
}
@@ -0,0 +1 @@
export * from "./tax-countries-section"
@@ -0,0 +1,69 @@
import { GlobeEurope } from "@medusajs/icons"
import { Region } from "@medusajs/medusa"
import { Container, Heading, Text, Tooltip } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
type TaxCountriesSectionProps = {
region: Region
}
export const TaxCountriesSection = ({ region }: TaxCountriesSectionProps) => {
const { t } = useTranslation()
const countries = region.countries ?? []
const firstCountries = countries.slice(0, 3)
const restCountries = countries.slice(3)
return (
<Container className="flex flex-col gap-y-4 px-6 py-4">
<div className="flex items-center justify-between">
<Heading level="h2">{t("fields.countries")}</Heading>
</div>
<div className="grid grid-cols-[28px_1fr] items-center gap-x-3">
<div className="bg-ui-bg-base shadow-borders-base flex size-7 items-center justify-center rounded-md">
<div className="bg-ui-bg-component flex size-6 items-center justify-center rounded-[4px]">
<GlobeEurope className="text-ui-fg-subtle" />
</div>
</div>
{countries.length > 0 ? (
<div className="flex items-center gap-x-1">
<Text size="small" leading="compact">
{firstCountries.map((sc) => sc.display_name).join(", ")}
</Text>
{restCountries.length > 0 && (
<Tooltip
content={
<ul>
{restCountries.map((sc) => (
<li key={sc.id}>{sc.display_name}</li>
))}
</ul>
}
>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{t("general.plusCountMore", {
count: restCountries.length,
})}
</Text>
</Tooltip>
)}
</div>
) : (
<Text size="small" leading="compact" className="text-ui-fg-subtle">
{t("products.noSalesChannels")}
</Text>
)}
</div>
<div>
<Text className="text-ui-fg-subtle" size="small" leading="compact">
{t("taxes.countries.taxCountriesHint")}
</Text>
</div>
</Container>
)
}
@@ -0,0 +1 @@
export * from "./tax-default-tax-rate-section"
@@ -0,0 +1,56 @@
import { PencilSquare } from "@medusajs/icons"
import { Region } from "@medusajs/medusa"
import { Container, Heading, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { formatPercentage } from "../../../../../lib/percentage-helpers"
type TaxDefaultRateSectionProps = {
region: Region
}
export const TaxDefaultTaxRateSection = ({
region,
}: TaxDefaultRateSectionProps) => {
const { t } = useTranslation()
const defaultTaxCode = region.tax_code
const defaultTaxRate = region.tax_rate
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("taxes.defaultRate.sectionTitle")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
to: "tax-rates/default/edit",
icon: <PencilSquare />,
},
],
},
]}
/>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.rate")}
</Text>
<Text size="small" leading="compact">
{formatPercentage(defaultTaxRate)}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.code")}
</Text>
<Text size="small" leading="compact">
{defaultTaxCode || "-"}
</Text>
</div>
</Container>
)
}
@@ -0,0 +1 @@
export * from "./tax-general-section"
@@ -0,0 +1,93 @@
import { PencilSquare } from "@medusajs/icons"
import { Region } from "@medusajs/medusa"
import { Badge, Container, Heading, StatusBadge, Text } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { formatPercentage } from "../../../../../lib/percentage-helpers"
type Props = {
region: Region
}
export const TaxDetailsSection = ({ region }: Props) => {
const { t } = useTranslation()
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>{region.name}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.edit"),
icon: <PencilSquare />,
to: "edit",
},
],
},
]}
/>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("taxes.settings.taxProviderLabel")}
</Text>
<Text size="small" leading="compact">
{region.tax_provider_id
? region.tax_provider_id
: t("taxes.settings.systemTaxProviderLabel")}
</Text>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("taxes.settings.calculateTaxesAutomaticallyLabel")}
</Text>
<StatusBadge
color={region.automatic_taxes ? "green" : "grey"}
className="w-fit"
>
{region.automatic_taxes
? t("general.enabled")
: t("general.disabled")}
</StatusBadge>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("taxes.settings.applyTaxesOnGiftCardsLabel")}
</Text>
<StatusBadge
color={region.gift_cards_taxable ? "green" : "grey"}
className="w-fit"
>
{region.gift_cards_taxable
? t("general.enabled")
: t("general.disabled")}
</StatusBadge>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("fields.taxInclusivePricing")}
</Text>
<StatusBadge
color={region.includes_tax ? "green" : "grey"}
className="w-fit"
>
{region.includes_tax ? t("general.enabled") : t("general.disabled")}
</StatusBadge>
</div>
<div className="text-ui-fg-subtle grid grid-cols-2 items-center px-6 py-4">
<Text size="small" weight="plus" leading="compact">
{t("taxes.settings.defaultTaxRateLabel")}
</Text>
<div className="flex items-center gap-x-2">
<Text size="small" leading="compact">
{formatPercentage(region.tax_rate)}
</Text>
{region.tax_code && <Badge size="2xsmall">{region.tax_code}</Badge>}
</div>
</div>
</Container>
)
}
@@ -0,0 +1,61 @@
import type { TaxRate } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
import { ExclamationCircle, PencilSquare, Trash } from "@medusajs/icons"
import { usePrompt } from "@medusajs/ui"
import { useAdminDeleteTaxRate } from "medusa-react"
import { ActionMenu } from "../../../../../components/common/action-menu"
export const TaxRateActions = ({ taxRate }: { taxRate: TaxRate }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteTaxRate(taxRate.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("taxes.taxRate.deleteRateDescription", {
name: taxRate.name,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
label: t("taxes.taxRate.editRateAction"),
to: `tax-rates/${taxRate.id}/edit`,
icon: <PencilSquare />,
},
{
label: t("taxes.taxRate.editOverridesAction"),
to: `tax-rates/${taxRate.id}/edit-overrides`,
icon: <ExclamationCircle />,
},
],
},
{
actions: [
{
label: t("actions.delete"),
icon: <Trash />,
onClick: handleDelete,
},
],
},
]}
/>
)
}
@@ -0,0 +1,93 @@
import { Plus } from "@medusajs/icons"
import { Region } from "@medusajs/medusa"
import { Container, Heading } from "@medusajs/ui"
import { useAdminTaxRates } from "medusa-react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useTaxRateTableColumns } from "./use-tax-rate-table-columns"
import { useTaxRateTableFilters } from "./use-tax-rate-table-filters"
import { useTaxRateTableQuery } from "./use-tax-rate-table-query"
type TaxRatesSectionProps = {
region: Region
}
const PAGE_SIZE = 10
export const TaxRatesSection = ({ region }: TaxRatesSectionProps) => {
const { t } = useTranslation()
const { searchParams, raw } = useTaxRateTableQuery({ pageSize: PAGE_SIZE })
const { tax_rates, count, isLoading, isError, error } = useAdminTaxRates(
{
region_id: region.id,
expand: ["products", "product_types", "shipping_options"],
fields: [
"id",
"name",
"code",
"rate",
"created_at",
"updated_at",
"products.id",
"product_types.id",
"shipping_options.id",
],
...searchParams,
},
{
keepPreviousData: true,
}
)
const filters = useTaxRateTableFilters()
const columns = useTaxRateTableColumns()
const { table } = useDataTable({
data: tax_rates ?? [],
columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
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">{t("taxes.taxRate.sectionTitle")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
label: t("actions.create"),
to: "tax-rates/create",
icon: <Plus />,
},
],
},
]}
/>
</div>
<DataTable
table={table}
isLoading={isLoading}
count={count}
columns={columns}
pageSize={PAGE_SIZE}
queryObject={raw}
filters={filters}
pagination
search
orderBy={["name", "code", "rate", "created_at", "updated_at"]}
/>
</Container>
)
}
@@ -0,0 +1,127 @@
import type { TaxRate } from "@medusajs/medusa"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { PlaceholderCell } from "../../../../../components/table/table-cells/common/placeholder-cell"
import { formatPercentage } from "../../../../../lib/percentage-helpers"
import { TaxRateActions } from "./tax-rate-actions"
const columnHelper = createColumnHelper<TaxRate>()
export const useTaxRateTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("name", {
header: t("fields.name"),
cell: ({ getValue }) => {
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{getValue()}</span>
</div>
)
},
}),
columnHelper.accessor("code", {
header: t("fields.code"),
cell: ({ getValue }) => {
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{getValue()}</span>
</div>
)
},
}),
columnHelper.accessor("rate", {
header: t("fields.rate"),
cell: ({ getValue }) => {
const rate = getValue()
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{formatPercentage(rate)}</span>
</div>
)
},
}),
columnHelper.accessor("products", {
header: () => {
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">
{t("taxes.taxRate.productOverridesHeader")}
</span>
</div>
)
},
cell: ({ getValue }) => {
const count = getValue()?.length
if (!count) {
return <PlaceholderCell />
}
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{count}</span>
</div>
)
},
}),
columnHelper.accessor("product_types", {
header: () => {
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">
{t("taxes.taxRate.productTypeOverridesHeader")}
</span>
</div>
)
},
cell: ({ getValue }) => {
const count = getValue()?.length
if (!count) {
return <PlaceholderCell />
}
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{count}</span>
</div>
)
},
}),
columnHelper.accessor("shipping_options", {
header: () => {
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">
{t("taxes.taxRate.shippingOptionOverridesHeader")}
</span>
</div>
)
},
cell: ({ getValue }) => {
const count = getValue()?.length
if (!count) {
return <PlaceholderCell />
}
return (
<div className="flex items-center overflow-hidden">
<span className="truncate">{count}</span>
</div>
)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <TaxRateActions taxRate={row.original} />,
}),
],
[t]
)
}
@@ -0,0 +1,19 @@
import { useTranslation } from "react-i18next"
import type { 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
}
@@ -0,0 +1,29 @@
import { AdminGetTaxRatesParams } from "@medusajs/medusa"
import { useQueryParams } from "../../../../../hooks/use-query-params"
export const useTaxRateTableQuery = ({
pageSize = 10,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(
["offset", "q", "order", "created_at", "updated_at"],
prefix
)
const searchParams: AdminGetTaxRatesParams = {
limit: pageSize,
offset: raw.offset ? parseInt(raw.offset) : 0,
q: raw.q,
order: raw.order,
created_at: raw.created_at ? JSON.parse(raw.created_at) : undefined,
updated_at: raw.updated_at ? JSON.parse(raw.updated_at) : undefined,
}
return {
searchParams,
raw,
}
}
@@ -0,0 +1,2 @@
export { taxRegionLoader as loader } from "./loader"
export { TaxDetail as Component } from "./tax-detail"
@@ -0,0 +1,21 @@
import { AdminRegionsRes } from "@medusajs/medusa"
import { Response } from "@medusajs/medusa-js"
import { adminRegionKeys } from "medusa-react"
import { LoaderFunctionArgs } from "react-router-dom"
import { medusa, queryClient } from "../../../lib/medusa"
const regionQuery = (id: string) => ({
queryKey: adminRegionKeys.detail(id),
queryFn: async () => medusa.admin.regions.retrieve(id),
})
export const taxRegionLoader = async ({ params }: LoaderFunctionArgs) => {
const id = params.id
const query = regionQuery(id!)
return (
queryClient.getQueryData<Response<AdminRegionsRes>>(query.queryKey) ??
(await queryClient.fetchQuery(query))
)
}
@@ -0,0 +1,45 @@
import { useAdminRegion } from "medusa-react"
import { Outlet, useLoaderData, useParams } from "react-router-dom"
import { JsonViewSection } from "../../../components/common/json-view-section"
import { TaxCountriesSection } from "./components/tax-countries-section"
import { TaxDetailsSection } from "./components/tax-general-section"
import { TaxRatesSection } from "./components/tax-rates-section"
import { taxRegionLoader } from "./loader"
export const TaxDetail = () => {
const { id } = useParams()
const initialData = useLoaderData() as Awaited<
ReturnType<typeof taxRegionLoader>
>
const { region, isLoading, isError, error } = useAdminRegion(id!, {
initialData,
})
if (isLoading || !region) {
return <div>Loading...</div>
}
if (isError) {
throw error
}
return (
<div className="flex flex-col gap-y-2">
<div className="grid grid-cols-1 gap-x-4 xl:grid-cols-[1fr,400px]">
<div className="flex flex-col gap-y-2">
<TaxDetailsSection region={region} />
<TaxRatesSection region={region} />
<div className="flex flex-col gap-y-2 xl:hidden">
<TaxCountriesSection region={region} />
</div>
<JsonViewSection data={region} />
</div>
<div className="hidden flex-col gap-y-2 xl:flex">
<TaxCountriesSection region={region} />
</div>
</div>
<Outlet />
</div>
)
}
@@ -0,0 +1,257 @@
import { useForm } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { Region, TaxProvider } from "@medusajs/medusa"
import { Button, Input, Select, Switch } from "@medusajs/ui"
import { useAdminUpdateRegion } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import {
RouteDrawer,
useRouteModal,
} from "../../../../../components/route-modal"
import { formatProvider } from "../../../../../lib/format-provider"
type EditTaxRateFormProps = {
region: Region
taxProviders: TaxProvider[]
}
const EditTaxSettingsSchema = z.object({
tax_code: z.string().optional(),
tax_rate: z.union([z.string(), z.number()]).refine((value) => {
if (value === "") {
return false
}
const num = Number(value)
if (num >= 0 && num <= 100) {
return true
}
return false
}, "Default tax rate must be a number between 0 and 100"),
includes_tax: z.boolean(),
automatic_taxes: z.boolean(),
gift_cards_taxable: z.boolean(),
tax_provider_id: z.string(),
})
const SYSTEM_PROVIDER_ID = "system"
export const EditTaxSettingsForm = ({
region,
taxProviders,
}: EditTaxRateFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof EditTaxSettingsSchema>>({
defaultValues: {
tax_code: region.tax_code || "",
tax_rate: region.tax_rate,
includes_tax: region.includes_tax,
automatic_taxes: region.automatic_taxes,
gift_cards_taxable: region.gift_cards_taxable,
tax_provider_id: region.tax_provider_id || SYSTEM_PROVIDER_ID,
},
resolver: zodResolver(EditTaxSettingsSchema),
})
const { mutateAsync, isLoading } = useAdminUpdateRegion(region.id)
const handleSubmit = form.handleSubmit(async (data) => {
const { tax_provider_id, tax_rate, ...rest } = data
await mutateAsync(
{
tax_provider_id:
tax_provider_id === SYSTEM_PROVIDER_ID ? null : tax_provider_id,
tax_rate: Number(tax_rate),
...rest,
},
{
onSuccess: () => {
handleSuccess()
},
}
)
})
return (
<RouteDrawer.Form form={form}>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
<RouteDrawer.Body>
<div className="flex flex-col gap-y-8">
<div className="flex flex-col gap-y-4">
<Form.Field
control={form.control}
name="tax_provider_id"
render={({ field: { onChange, ref, ...field } }) => {
return (
<Form.Item>
<Form.Label>
{t("taxes.settings.taxProviderLabel")}
</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
<Select.Item value={SYSTEM_PROVIDER_ID}>
{t("taxes.settings.systemTaxProviderLabel")}
</Select.Item>
{taxProviders.map((tp) => (
<Select.Item key={tp.id} value={tp.id}>
{formatProvider(tp.id)}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tax_rate"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("taxes.settings.defaultTaxRateLabel")}
</Form.Label>
<Form.Control>
<PercentageInput {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="tax_code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>
{t("taxes.settings.defaultTaxCodeLabel")}
</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
<Form.Field
control={form.control}
name="automatic_taxes"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="grid grid-cols-[1fr_32px] items-start gap-4">
<div className="flex flex-col gap-y-1">
<Form.Label>
{t("taxes.settings.calculateTaxesAutomaticallyLabel")}
</Form.Label>
<Form.Hint className="text-pretty">
{t("taxes.settings.calculateTaxesAutomaticallyHint")}
</Form.Hint>
</div>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="gift_cards_taxable"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="grid grid-cols-[1fr_32px] items-start gap-4">
<div className="flex flex-col gap-y-1">
<Form.Label>
{t("taxes.settings.applyTaxesOnGiftCardsLabel")}
</Form.Label>
<Form.Hint className="text-pretty">
{t("taxes.settings.applyTaxesOnGiftCardsHint")}
</Form.Hint>
</div>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="includes_tax"
render={({ field: { value, onChange, ...field } }) => {
return (
<Form.Item>
<div className="grid grid-cols-[1fr_32px] items-start gap-4">
<div className="flex flex-col gap-y-1">
<Form.Label>
{t("fields.taxInclusivePricing")}
</Form.Label>
<Form.Hint className="text-pretty">
{t("regions.taxInclusiveHint")}
</Form.Hint>
</div>
<Form.Control>
<Switch
{...field}
checked={value}
onCheckedChange={onChange}
/>
</Form.Control>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center gap-x-2">
<RouteDrawer.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteDrawer.Form>
)
}
@@ -0,0 +1 @@
export * from "./edit-tax-form"
@@ -0,0 +1 @@
export { TaxEdit as Component } from "./tax-edit"
@@ -0,0 +1,40 @@
import { Heading } from "@medusajs/ui"
import { useAdminRegion, useAdminStoreTaxProviders } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { EditTaxSettingsForm } from "./components/edit-tax-form"
export const TaxEdit = () => {
const { id } = useParams()
const { t } = useTranslation()
const { region, isLoading, isError, error } = useAdminRegion(id!)
const {
tax_providers,
isLoading: isLoadingTaxProvider,
isError: isTaxProviderError,
error: taxProviderError,
} = useAdminStoreTaxProviders()
const ready = !isLoading && region && !isLoadingTaxProvider && tax_providers
if (isError) {
throw error
}
if (isTaxProviderError) {
throw taxProviderError
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("taxes.settings.editTaxSettings")}</Heading>
</RouteDrawer.Header>
{ready && (
<EditTaxSettingsForm region={region} taxProviders={tax_providers} />
)}
</RouteDrawer>
)
}
@@ -0,0 +1 @@
export * from "./tax-list-callout"
@@ -0,0 +1,24 @@
import { Button, Container, Heading, Text } from "@medusajs/ui"
import { Link } from "react-router-dom"
export const TaxListCallout = () => {
return (
<Container className="flex items-center justify-between gap-x-3 px-6 py-4">
<div>
<Heading>Taxes</Heading>
<Text size="small" className="text-ui-fg-subtle text-pretty">
Tax settings are specific to each region. To modify tax settings,
please select a region from the list.
</Text>
</div>
<Button
size="small"
variant="secondary"
asChild
className="shrink-0 whitespace-nowrap"
>
<Link to="/settings/regions">Go to Region settings</Link>
</Button>
</Container>
)
}
@@ -0,0 +1 @@
export * from "../tax-list-table/tax-list-table"
@@ -0,0 +1,70 @@
import { Container, Heading } from "@medusajs/ui"
import { useAdminRegions } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { DataTable } from "../../../../../components/table/data-table"
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"
const PAGE_SIZE = 20
export const TaxListTable = () => {
const { t } = useTranslation()
const { searchParams, raw } = useRegionTableQuery({
pageSize: PAGE_SIZE,
})
const { regions, count, isLoading, isError, error } = useAdminRegions(
{
...searchParams,
},
{
keepPreviousData: true,
}
)
const columns = useColumns()
const filters = useRegionTableFilters()
const { table } = useDataTable({
data: regions || [],
columns,
count,
enablePagination: true,
getRowId: (row) => row.id,
pageSize: PAGE_SIZE,
})
if (isError) {
throw error
}
return (
<Container className="divide-y p-0">
<div className="px-6 py-4">
<Heading level="h2">{t("regions.domain")}</Heading>
</div>
<DataTable
table={table}
columns={columns}
filters={filters}
isLoading={isLoading}
queryObject={raw}
pageSize={PAGE_SIZE}
count={count}
pagination
search
navigateTo={(row) => `${row.original.id}`}
orderBy={["name", "created_at", "updated_at"]}
/>
</Container>
)
}
const useColumns = () => {
const base = useRegionTableColumns()
return useMemo(() => [...base], [base])
}
@@ -0,0 +1,11 @@
import { TaxListCallout } from "./components/tax-list-callout"
import { TaxListTable } from "./components/tax-list-table"
export const TaxList = () => {
return (
<div className="flex flex-col gap-y-2">
<TaxListCallout />
<TaxListTable />
</div>
)
}
@@ -0,0 +1,474 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Button, Heading, Input, Switch, Text, clx } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useAdminCreateTaxRate } from "medusa-react"
import { useState } from "react"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { useParams, useSearchParams } from "react-router-dom"
import { z } from "zod"
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 { OverrideGrid } from "../../../common/components/override-grid"
import { OverrideTable } from "../../../common/components/overrides-drawer/overrides-drawer"
import { Override } from "../../../common/constants"
import { OverrideOption, OverrideState } from "../../../common/types"
const CreateTaxRateSchema = z.object({
name: z.string().min(1),
code: z.string().min(1),
rate: z.union([z.string(), z.number()]).refine((value) => {
if (value === "") {
return false
}
const num = Number(value)
if (num >= 0 && num <= 100) {
return true
}
return false
}, "Tax rate must be a number between 0 and 100"),
products: z.array(
z.object({
label: z.string(),
value: z.string(),
})
),
product_types: z.array(
z.object({
label: z.string(),
value: z.string(),
})
),
shipping_options: z.array(
z.object({
label: z.string(),
value: z.string(),
})
),
})
export const CreateTaxRateForm = () => {
const [open, setOpen] = useState(false)
const [override, setOverride] = useState<Override | null>(null)
const [state, setState] = useState<OverrideState>({
[Override.PRODUCT]: false,
[Override.PRODUCT_TYPE]: false,
[Override.SHIPPING_OPTION]: false,
})
const [, setSearchParams] = useSearchParams()
const { id } = useParams()
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof CreateTaxRateSchema>>({
defaultValues: {
name: "",
code: "",
rate: "",
products: [],
product_types: [],
shipping_options: [],
},
resolver: zodResolver(CreateTaxRateSchema),
})
const selectedProducts = useWatch({
control: form.control,
name: "products",
defaultValue: [],
})
const selectedProductTypes = useWatch({
control: form.control,
name: "product_types",
defaultValue: [],
})
const selectedShippingOptions = useWatch({
control: form.control,
name: "shipping_options",
defaultValue: [],
})
const { mutateAsync, isLoading } = useAdminCreateTaxRate()
const handleSubmit = form.handleSubmit(async (data) => {
const { rate, products, product_types, shipping_options, ...rest } = data
const productsPaylaod = state[Override.PRODUCT]
? products.map((p) => p.value)
: undefined
const productTypesPayload = state[Override.PRODUCT_TYPE]
? product_types.map((p) => p.value)
: undefined
const shippingOptionsPayload = state[Override.SHIPPING_OPTION]
? shipping_options.map((p) => p.value)
: undefined
await mutateAsync(
{
region_id: id!,
rate: Number(rate),
products: productsPaylaod,
product_types: productTypesPayload,
shipping_options: shippingOptionsPayload,
...rest,
},
{
onSuccess: () => {
handleSuccess()
},
}
)
})
const handleOpenDrawer = (override: Override) => {
setOverride(override)
setOpen(true)
}
const handleSaveOverrides = (override: Override) => {
let field: "products" | "product_types" | "shipping_options"
switch (override) {
case Override.PRODUCT:
field = "products"
break
case Override.PRODUCT_TYPE:
field = "product_types"
break
case Override.SHIPPING_OPTION:
field = "shipping_options"
break
}
return (options: OverrideOption[]) => {
form.setValue(field, options, {
shouldDirty: true,
shouldTouch: true,
})
setOpen(false)
}
}
const handleRemoveOverride = (override: Override) => {
let field: "products" | "product_types" | "shipping_options"
switch (override) {
case Override.PRODUCT:
field = "products"
break
case Override.PRODUCT_TYPE:
field = "product_types"
break
case Override.SHIPPING_OPTION:
field = "shipping_options"
break
}
return (value: string) => {
const current = form.getValues(field)
const newValue = current.filter((c: OverrideOption) => c.value !== value)
form.setValue(field, newValue, {
shouldDirty: true,
shouldTouch: true,
})
}
}
const handleClearOverrides = (override: Override) => {
let field: "products" | "product_types" | "shipping_options"
switch (override) {
case Override.PRODUCT:
field = "products"
break
case Override.PRODUCT_TYPE:
field = "product_types"
break
case Override.SHIPPING_OPTION:
field = "shipping_options"
break
}
return () => {
form.setValue(field, [], {
shouldDirty: true,
shouldTouch: true,
})
}
}
const handleStateChange = (override: Override) => {
return (enabled: boolean) => {
setState((prev) => ({
...prev,
[override]: enabled,
}))
}
}
const handleOpenChange = (open: boolean) => {
if (!open) {
setOverride(null)
setSearchParams(
{},
{
replace: true,
}
)
}
setOpen(open)
}
return (
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<RouteFocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{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 h-full w-full flex-col items-center overflow-y-auto p-16"
)}
id="form-section"
>
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("taxes.taxRate.createTaxRate")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("taxes.taxRate.createTaxRateHint")}
</Text>
</div>
<div className="flex flex-col gap-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<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.ErrorMessage />
</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} />
</Form.Control>
<Form.ErrorMessage />
</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.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
<div className="bg-ui-border-base h-px w-full" />
<Collapsible.Root
open={state[Override.PRODUCT]}
onOpenChange={handleStateChange(Override.PRODUCT)}
>
<div className="grid grid-cols-[1fr_32px] items-start gap-x-4">
<div className="flex flex-col">
<Text size="small" weight="plus" leading="compact">
{t("taxes.taxRate.productOverridesLabel")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("taxes.taxRate.productOverridesHint")}
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch className="data-[state=open]:bg-ui-bg-interactive" />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-4 pt-4">
<OverrideGrid
overrides={selectedProducts}
onClear={handleClearOverrides(Override.PRODUCT)}
onRemove={handleRemoveOverride(Override.PRODUCT)}
/>
<div className="flex items-center justify-end">
<Button
size="small"
type="button"
variant="secondary"
onClick={() => handleOpenDrawer(Override.PRODUCT)}
>
{t("taxes.taxRate.addProductOverridesAction")}
</Button>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
<div className="bg-ui-border-base h-px w-full" />
<Collapsible.Root
open={state[Override.PRODUCT_TYPE]}
onOpenChange={handleStateChange(Override.PRODUCT_TYPE)}
>
<div className="grid grid-cols-[1fr_32px] items-start gap-x-4">
<div className="flex flex-col">
<Text size="small" weight="plus" leading="compact">
{t("taxes.taxRate.productTypeOverridesLabel")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("taxes.taxRate.productTypeOverridesHint")}
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch className="data-[state=open]:bg-ui-bg-interactive" />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-4 pt-4">
<OverrideGrid
overrides={selectedProductTypes}
onClear={handleClearOverrides(Override.PRODUCT_TYPE)}
onRemove={handleRemoveOverride(Override.PRODUCT_TYPE)}
/>
<div className="flex items-center justify-end">
<Button
size="small"
type="button"
variant="secondary"
onClick={() =>
handleOpenDrawer(Override.PRODUCT_TYPE)
}
>
{t("taxes.taxRate.addProductTypeOverridesAction")}
</Button>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
<div className="bg-ui-border-base h-px w-full" />
<Collapsible.Root
open={state[Override.SHIPPING_OPTION]}
onOpenChange={handleStateChange(Override.SHIPPING_OPTION)}
>
<div className="grid grid-cols-[1fr_32px] items-start gap-x-4">
<div className="flex flex-col">
<Text size="small" weight="plus" leading="compact">
{t("taxes.taxRate.shippingOptionOverridesLabel")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("taxes.taxRate.shippingOptionOverridesHint")}
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch className="data-[state=open]:bg-ui-bg-interactive" />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-4 pt-4">
<OverrideGrid
overrides={selectedShippingOptions}
onClear={handleClearOverrides(
Override.SHIPPING_OPTION
)}
onRemove={handleRemoveOverride(
Override.SHIPPING_OPTION
)}
/>
<div className="flex items-center justify-end">
<Button
size="small"
type="button"
variant="secondary"
onClick={() =>
handleOpenDrawer(Override.SHIPPING_OPTION)
}
>
{t(
"taxes.taxRate.addShippingOptionOverridesAction"
)}
</Button>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
</div>
</SplitView.Content>
<SplitView.Drawer>
<OverrideTable
product={{
selected: selectedProducts,
onSave: handleSaveOverrides(Override.PRODUCT),
}}
productType={{
selected: selectedProductTypes,
onSave: handleSaveOverrides(Override.PRODUCT_TYPE),
}}
shippingOption={{
selected: selectedShippingOptions,
onSave: handleSaveOverrides(Override.SHIPPING_OPTION),
}}
regionId={id!}
selected={override}
/>
</SplitView.Drawer>
</SplitView>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
@@ -0,0 +1 @@
export * from "./create-tax-rate-form"
@@ -0,0 +1 @@
export { TaxRateCreate as Component } from "./tax-rate-create"
@@ -0,0 +1,10 @@
import { RouteFocusModal } from "../../../components/route-modal"
import { CreateTaxRateForm } from "./components/create-tax-rate-form"
export const TaxRateCreate = () => {
return (
<RouteFocusModal>
<CreateTaxRateForm />
</RouteFocusModal>
)
}
@@ -0,0 +1,414 @@
import { TaxRate } from "@medusajs/medusa"
import { Button, Heading, Switch, Text, clx } from "@medusajs/ui"
import * as Collapsible from "@radix-ui/react-collapsible"
import { useAdminUpdateTaxRate } from "medusa-react"
import { useState } from "react"
import { useForm, useWatch } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useSearchParams } from "react-router-dom"
import { SplitView } from "../../../../../components/layout/split-view"
import {
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
import { OverrideGrid } from "../../../common/components/override-grid"
import { OverrideTable } from "../../../common/components/overrides-drawer/overrides-drawer"
import { Override } from "../../../common/constants"
import { OverrideOption, OverrideState } from "../../../common/types"
type EditTaxRateOverridesFormProps = {
taxRate: TaxRate
}
const EditTaxRateOverridesSchema = z.object({
products: z.array(
z.object({
label: z.string(),
value: z.string(),
})
),
product_types: z.array(
z.object({
label: z.string(),
value: z.string(),
})
),
shipping_options: z.array(
z.object({
label: z.string(),
value: z.string(),
})
),
})
export const EditTaxRateOverridesForm = ({
taxRate,
}: EditTaxRateOverridesFormProps) => {
const [open, setOpen] = useState(false)
const [override, setOverride] = useState<Override | null>(null)
const [state, setState] = useState<OverrideState>({
[Override.PRODUCT]: taxRate.products?.length > 0,
[Override.PRODUCT_TYPE]: taxRate.product_types?.length > 0,
[Override.SHIPPING_OPTION]: taxRate.shipping_options?.length > 0,
})
const [, setSearchParams] = useSearchParams()
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof EditTaxRateOverridesSchema>>({
defaultValues: {
products:
taxRate.products?.map((p) => ({
label: p.title,
value: p.id,
})) || [],
product_types:
taxRate.product_types?.map((pt) => ({
label: pt.value,
value: pt.id,
})) || [],
shipping_options:
taxRate.shipping_options?.map((so) => ({
label: so.name,
value: so.id,
})) || [],
},
resolver: zodResolver(EditTaxRateOverridesSchema),
})
const selectedProducts = useWatch({
control: form.control,
name: "products",
})
const selectedProductTypes = useWatch({
control: form.control,
name: "product_types",
})
const selectedShippingOptions = useWatch({
control: form.control,
name: "shipping_options",
})
const { mutateAsync, isLoading } = useAdminUpdateTaxRate(taxRate.id)
const handleSubmit = form.handleSubmit(async (data) => {
const { products, product_types, shipping_options } = data
const productsPaylaod = state[Override.PRODUCT]
? products.map((p) => p.value)
: []
const productTypesPayload = state[Override.PRODUCT_TYPE]
? product_types.map((p) => p.value)
: []
const shippingOptionsPayload = state[Override.SHIPPING_OPTION]
? shipping_options.map((p) => p.value)
: []
await mutateAsync(
{
products: productsPaylaod,
product_types: productTypesPayload,
shipping_options: shippingOptionsPayload,
},
{
onSuccess: () => {
handleSuccess()
},
}
)
})
const handleOpenDrawer = (override: Override) => {
setOverride(override)
setOpen(true)
}
const handleSaveOverrides = (override: Override) => {
let field: "products" | "product_types" | "shipping_options"
switch (override) {
case Override.PRODUCT:
field = "products"
break
case Override.PRODUCT_TYPE:
field = "product_types"
break
case Override.SHIPPING_OPTION:
field = "shipping_options"
break
}
return (options: OverrideOption[]) => {
form.setValue(field, options, {
shouldDirty: true,
shouldTouch: true,
})
setOpen(false)
}
}
const handleRemoveOverride = (override: Override) => {
let field: "products" | "product_types" | "shipping_options"
switch (override) {
case Override.PRODUCT:
field = "products"
break
case Override.PRODUCT_TYPE:
field = "product_types"
break
case Override.SHIPPING_OPTION:
field = "shipping_options"
break
}
return (value: string) => {
const current = form.getValues(field)
const newValue = current.filter((c: OverrideOption) => c.value !== value)
form.setValue(field, newValue, {
shouldDirty: true,
shouldTouch: true,
})
}
}
const handleClearOverrides = (override: Override) => {
let field: "products" | "product_types" | "shipping_options"
switch (override) {
case Override.PRODUCT:
field = "products"
break
case Override.PRODUCT_TYPE:
field = "product_types"
break
case Override.SHIPPING_OPTION:
field = "shipping_options"
break
}
return () => {
form.setValue(field, [], {
shouldDirty: true,
shouldTouch: true,
})
}
}
const handleStateChange = (override: Override) => {
return (enabled: boolean) => {
setState((prev) => ({
...prev,
[override]: enabled,
}))
}
}
const handleOpenChange = (open: boolean) => {
if (!open) {
setOverride(null)
setSearchParams(
{},
{
replace: true,
}
)
}
setOpen(open)
}
return (
<RouteFocusModal.Form form={form}>
<form
className="flex h-full flex-col overflow-hidden"
onSubmit={handleSubmit}
>
<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={isLoading}>
{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 h-full w-full flex-col items-center overflow-y-auto p-16"
)}
>
<div className="flex w-full max-w-[720px] flex-col gap-y-8">
<div>
<Heading>{t("taxes.taxRate.editOverridesTitle")}</Heading>
<Text size="small" className="text-ui-fg-subtle">
{t("taxes.taxRate.editOverridesHint")}
</Text>
</div>
<Collapsible.Root
open={state[Override.PRODUCT]}
onOpenChange={handleStateChange(Override.PRODUCT)}
>
<div className="grid grid-cols-[1fr_32px] items-start gap-x-4">
<div className="flex flex-col">
<Text size="small" weight="plus" leading="compact">
{t("taxes.taxRate.productOverridesLabel")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("taxes.taxRate.productOverridesHint")}
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch className="data-[state=open]:bg-ui-bg-interactive" />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-4 pt-4">
<OverrideGrid
overrides={selectedProducts}
onClear={handleClearOverrides(Override.PRODUCT)}
onRemove={handleRemoveOverride(Override.PRODUCT)}
/>
<div className="flex items-center justify-end">
<Button
size="small"
type="button"
variant="secondary"
onClick={() => handleOpenDrawer(Override.PRODUCT)}
>
{t("taxes.taxRate.addProductOverridesAction")}
</Button>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
<div className="bg-ui-border-base h-px w-full" />
<Collapsible.Root
open={state[Override.PRODUCT_TYPE]}
onOpenChange={handleStateChange(Override.PRODUCT_TYPE)}
>
<div className="grid grid-cols-[1fr_32px] items-start gap-x-4">
<div className="flex flex-col">
<Text size="small" weight="plus" leading="compact">
{t("taxes.taxRate.productTypeOverridesLabel")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("taxes.taxRate.productTypeOverridesHint")}
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch className="data-[state=open]:bg-ui-bg-interactive" />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-4 pt-4">
<OverrideGrid
overrides={selectedProductTypes}
onClear={handleClearOverrides(Override.PRODUCT_TYPE)}
onRemove={handleRemoveOverride(Override.PRODUCT_TYPE)}
/>
<div className="flex items-center justify-end">
<Button
size="small"
type="button"
variant="secondary"
onClick={() =>
handleOpenDrawer(Override.PRODUCT_TYPE)
}
>
{t("taxes.taxRate.addProductTypeOverridesAction")}
</Button>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
<div className="bg-ui-border-base h-px w-full" />
<Collapsible.Root
open={state[Override.SHIPPING_OPTION]}
onOpenChange={handleStateChange(Override.SHIPPING_OPTION)}
>
<div className="grid grid-cols-[1fr_32px] items-start gap-x-4">
<div className="flex flex-col">
<Text size="small" weight="plus" leading="compact">
{t("taxes.taxRate.shippingOptionOverridesLabel")}
</Text>
<Text size="small" className="text-ui-fg-subtle">
{t("taxes.taxRate.shippingOptionOverridesHint")}
</Text>
</div>
<Collapsible.Trigger asChild>
<Switch className="data-[state=open]:bg-ui-bg-interactive" />
</Collapsible.Trigger>
</div>
<Collapsible.Content>
<div className="flex flex-col gap-y-4 pt-4">
<OverrideGrid
overrides={selectedShippingOptions}
onClear={handleClearOverrides(
Override.SHIPPING_OPTION
)}
onRemove={handleRemoveOverride(
Override.SHIPPING_OPTION
)}
/>
<div className="flex items-center justify-end">
<Button
size="small"
type="button"
variant="secondary"
onClick={() =>
handleOpenDrawer(Override.SHIPPING_OPTION)
}
>
{t(
"taxes.taxRate.addShippingOptionOverridesAction"
)}
</Button>
</div>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
</div>
</SplitView.Content>
<SplitView.Drawer>
<OverrideTable
product={{
selected: selectedProducts,
onSave: handleSaveOverrides(Override.PRODUCT),
}}
productType={{
selected: selectedProductTypes,
onSave: handleSaveOverrides(Override.PRODUCT_TYPE),
}}
shippingOption={{
selected: selectedShippingOptions,
onSave: handleSaveOverrides(Override.SHIPPING_OPTION),
}}
regionId={taxRate.region_id}
selected={override}
/>
</SplitView.Drawer>
</SplitView>
</RouteFocusModal.Body>
</form>
</RouteFocusModal.Form>
)
}
@@ -0,0 +1 @@
export * from "./edit-tax-rate-overrides-form"
@@ -0,0 +1 @@
export { TaxRateEditOverrides as Component } from "./tax-rate-edit-overrides"
@@ -0,0 +1,24 @@
import { useAdminTaxRate } from "medusa-react"
import { useParams } from "react-router-dom"
import { RouteFocusModal } from "../../../components/route-modal"
import { EditTaxRateOverridesForm } from "./components/edit-tax-rate-overrides-form"
export const TaxRateEditOverrides = () => {
const { rate_id } = useParams()
const { tax_rate, isLoading, isError, error } = useAdminTaxRate(rate_id!, {
expand: ["products", "shipping_options", "product_types"],
})
const ready = !isLoading && tax_rate
if (isError) {
throw error
}
return (
<RouteFocusModal>
{ready && <EditTaxRateOverridesForm taxRate={tax_rate} />}
</RouteFocusModal>
)
}
@@ -0,0 +1,133 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { TaxRate } from "@medusajs/medusa"
import { Button, Input } from "@medusajs/ui"
import { useAdminUpdateTaxRate } from "medusa-react"
import { useTranslation } from "react-i18next"
import { Form } from "../../../../../components/common/form"
import { PercentageInput } from "../../../../../components/common/percentage-input"
import {
RouteDrawer,
RouteFocusModal,
useRouteModal,
} from "../../../../../components/route-modal"
type EditTaxRateFormProps = {
taxRate: TaxRate
}
const EditTaxRateSchema = z.object({
name: z.string().min(1),
code: z.string().min(1),
rate: z.union([z.string(), z.number()]).refine((value) => {
if (value === "") {
return true // we allow empty string and read it as null
}
const num = Number(value)
if (num >= 0 && num <= 100) {
return true
}
return false
}, "Tax rate must be a number between 0 and 100"),
})
export const EditTaxRateForm = ({ taxRate }: EditTaxRateFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const form = useForm<z.infer<typeof EditTaxRateSchema>>({
defaultValues: {
name: taxRate.name,
code: taxRate.code || "",
rate: taxRate.rate || "",
},
resolver: zodResolver(EditTaxRateSchema),
})
const { mutateAsync, isLoading } = useAdminUpdateTaxRate(taxRate.id)
const handleSubmit = form.handleSubmit(async (data) => {
await mutateAsync(
{
...data,
rate: data.rate ? Number(data.rate) : null,
},
{
onSuccess: () => {
handleSuccess()
},
}
)
})
return (
<RouteFocusModal.Form form={form}>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col">
<RouteDrawer.Body className="flex flex-col gap-y-4">
<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.ErrorMessage />
</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} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="code"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.code")}</Form.Label>
<Form.Control>
<Input type="number" {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</RouteDrawer.Body>
<RouteDrawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteDrawer.Close asChild>
<Button variant="secondary" size="small">
{t("actions.cancel")}
</Button>
</RouteDrawer.Close>
<Button type="submit" size="small" isLoading={isLoading}>
{t("actions.save")}
</Button>
</div>
</RouteDrawer.Footer>
</form>
</RouteFocusModal.Form>
)
}
@@ -0,0 +1 @@
export * from "./edit-tax-rate-form"
@@ -0,0 +1 @@
export { TaxRateEdit as Component } from "./tax-rate-edit"
@@ -0,0 +1,28 @@
import { Heading } from "@medusajs/ui"
import { useAdminTaxRate } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { RouteDrawer } from "../../../components/route-modal"
import { EditTaxRateForm } from "./components/edit-tax-rate-form"
export const TaxRateEdit = () => {
const { rate_id } = useParams()
const { t } = useTranslation()
const { tax_rate, isLoading, isError, error } = useAdminTaxRate(rate_id!)
const ready = !isLoading && tax_rate
if (isError) {
throw error
}
return (
<RouteDrawer>
<RouteDrawer.Header>
<Heading>{t("taxes.taxRate.editTaxRate")}</Heading>
</RouteDrawer.Header>
{ready && <EditTaxRateForm taxRate={tax_rate} />}
</RouteDrawer>
)
}
@@ -1 +0,0 @@
export { TaxDetails as Component } from "./tax-details"
@@ -1,34 +0,0 @@
import { useAdminRegion } from "medusa-react"
import { useParams } from "react-router-dom"
import { TaxDetailsSection } from "../../components/tax-details-section"
import { TaxRatesSection } from "../../components/tax-rates-section"
export const TaxDetails = () => {
const { id } = useParams()
const { region, isLoading, isError, error } = useAdminRegion(id!)
if (isLoading) {
return <div>Loading</div>
}
if (isError || !region) {
const err = error ? JSON.parse(JSON.stringify(error)) : null
return (
<div>
{(err as Error & { status: number })?.status === 404 ? (
<div>Not found</div>
) : (
<div>Something went wrong!</div>
)}
</div>
)
}
return (
<div className="flex flex-col gap-y-2">
<TaxDetailsSection region={region} />
<TaxRatesSection />
</div>
)
}
@@ -1,26 +0,0 @@
import { useAdminRegions } from "medusa-react"
import { Link } from "react-router-dom"
export const TaxList = () => {
const { regions, isLoading, isError, error } = useAdminRegions()
if (isLoading) {
return <div>Loading...</div>
}
if (isError || !regions) {
return <div>Error</div>
}
return (
<div>
{regions.map((region) => {
return (
<div key={region.id}>
<Link to={`/settings/taxes/${region.id}`}>{region.name}</Link>
</div>
)
})}
</div>
)
}
@@ -16,6 +16,69 @@ export interface AdminGetTaxRatesParams {
* Filter by code. * Filter by code.
*/ */
code?: string code?: string
/**
* Filter by a creation date range.
*/
created_at?: {
/**
* filter by dates less than this date
*/
lt?: string
/**
* filter by dates greater than this date
*/
gt?: string
/**
* filter by dates less than or equal to this date
*/
lte?: string
/**
* filter by dates greater than or equal to this date
*/
gte?: string
}
/**
* Filter by an update date range.
*/
updated_at?: {
/**
* filter by dates less than this date
*/
lt?: string
/**
* filter by dates greater than this date
*/
gt?: string
/**
* filter by dates less than or equal to this date
*/
lte?: string
/**
* filter by dates greater than or equal to this date
*/
gte?: string
}
/**
* Filter by a deletion date range.
*/
deleted_at?: {
/**
* filter by dates less than this date
*/
lt?: string
/**
* filter by dates greater than this date
*/
gt?: string
/**
* filter by dates less than or equal to this date
*/
lte?: string
/**
* filter by dates greater than or equal to this date
*/
gte?: string
}
/** /**
* Filter by Rate * Filter by Rate
*/ */
@@ -39,6 +102,14 @@ export interface AdminGetTaxRatesParams {
*/ */
gte?: number gte?: number
} }
/**
* Term used to search tax rates by name.
*/
q?: string
/**
* A tax rate field to sort-order the retrieved tax rates by.
*/
order?: string
/** /**
* The number of tax rates to skip when retrieving the tax rates. * The number of tax rates to skip when retrieving the tax rates.
*/ */
@@ -1,13 +1,23 @@
import { IsArray, IsNumber, IsOptional, IsString } from "class-validator" import {
import { getListConfig, pickByConfig } from "./utils/get-query-config" IsArray,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import { identity, omit, pickBy } from "lodash" import { identity, omit, pickBy } from "lodash"
import { getListConfig, pickByConfig } from "./utils/get-query-config"
import { IsType } from "../../../../utils/validators/is-type" import { isDefined } from "@medusajs/utils"
import { NumericalComparisonOperator } from "../../../../types/common" import { Type } from "class-transformer"
import { TaxRate } from "../../../.." import { TaxRate } from "../../../.."
import { TaxRateService } from "../../../../services" import { TaxRateService } from "../../../../services"
import { Type } from "class-transformer" import {
DateComparisonOperator,
NumericalComparisonOperator,
} from "../../../../types/common"
import { validator } from "../../../../utils/validator" import { validator } from "../../../../utils/validator"
import { IsType } from "../../../../utils/validators/is-type"
/** /**
* @oas [get] /admin/tax-rates * @oas [get] /admin/tax-rates
@@ -30,6 +40,72 @@ import { validator } from "../../../../utils/validator"
* type: string * type: string
* - (query) code {string} Filter by code. * - (query) code {string} Filter by code.
* - in: query * - in: query
* name: created_at
* description: Filter by a creation date range.
* schema:
* type: object
* properties:
* lt:
* type: string
* description: filter by dates less than this date
* format: date
* gt:
* type: string
* description: filter by dates greater than this date
* format: date
* lte:
* type: string
* description: filter by dates less than or equal to this date
* format: date
* gte:
* type: string
* description: filter by dates greater than or equal to this date
* format: date
* - in: query
* name: updated_at
* description: Filter by an update date range.
* schema:
* type: object
* properties:
* lt:
* type: string
* description: filter by dates less than this date
* format: date
* gt:
* type: string
* description: filter by dates greater than this date
* format: date
* lte:
* type: string
* description: filter by dates less than or equal to this date
* format: date
* gte:
* type: string
* description: filter by dates greater than or equal to this date
* format: date
* - in: query
* name: deleted_at
* description: Filter by a deletion date range.
* schema:
* type: object
* properties:
* lt:
* type: string
* description: filter by dates less than this date
* format: date
* gt:
* type: string
* description: filter by dates greater than this date
* format: date
* lte:
* type: string
* description: filter by dates less than or equal to this date
* format: date
* gte:
* type: string
* description: filter by dates greater than or equal to this date
* format: date
* - in: query
* name: rate * name: rate
* style: form * style: form
* explode: false * explode: false
@@ -51,6 +127,8 @@ import { validator } from "../../../../utils/validator"
* gte: * gte:
* type: number * type: number
* description: filter by rates greater than or equal to this number * description: filter by rates greater than or equal to this number
* - (query) q {string} Term used to search tax rates by name.
* - (query) order {string} A tax rate field to sort-order the retrieved tax rates by.
* - (query) offset=0 {integer} The number of tax rates to skip when retrieving the tax rates. * - (query) offset=0 {integer} The number of tax rates to skip when retrieving the tax rates.
* - (query) limit=50 {integer} Limit the number of tax rates returned. * - (query) limit=50 {integer} Limit the number of tax rates returned.
* - in: query * - in: query
@@ -151,7 +229,29 @@ export default async (req, res) => {
const rateService: TaxRateService = req.scope.resolve("taxRateService") const rateService: TaxRateService = req.scope.resolve("taxRateService")
const listConfig = getListConfig() const order = value.order
let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined
if (isDefined(order)) {
let orderField = order
if (order.startsWith("-")) {
const [, field] = order.split("-")
orderField = field
orderBy = { [field]: "DESC" }
} else {
orderBy = { [order]: "ASC" }
}
} else {
const defaultOrder: string = "created_at"
orderBy = { [defaultOrder]: "DESC" }
}
const listConfig = getListConfig(
value.fields as (keyof TaxRate)[],
value.expand,
value.limit,
value.offset,
orderBy
)
const filterableFields = omit(value, [ const filterableFields = omit(value, [
"limit", "limit",
@@ -234,4 +334,39 @@ export class AdminGetTaxRatesParams {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
fields?: string[] fields?: string[]
@IsOptional()
@IsString()
order?: string
/**
* Date filters to apply on the tax rates' `update_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
updated_at?: DateComparisonOperator
/**
* Date filters to apply on the customer tax rates' `created_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
created_at?: DateComparisonOperator
/**
* Date filters to apply on the tax rates' `deleted_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
deleted_at?: DateComparisonOperator
/**
* Term used to search tax rates by name.
*/
@IsOptional()
@IsString()
q?: string
} }
+29 -3
View File
@@ -1,5 +1,7 @@
import { promiseAll } from "@medusajs/utils"
import { isDefined, MedusaError } from "medusa-core-utils" import { isDefined, MedusaError } from "medusa-core-utils"
import { EntityManager, In } from "typeorm" import { EntityManager, ILike, In } from "typeorm"
import { TransactionBaseService } from "../interfaces"
import { import {
ProductTaxRate, ProductTaxRate,
ProductTypeTaxRate, ProductTypeTaxRate,
@@ -18,8 +20,6 @@ import {
UpdateTaxRateInput, UpdateTaxRateInput,
} from "../types/tax-rate" } from "../types/tax-rate"
import { buildQuery, PostgresError } from "../utils" import { buildQuery, PostgresError } from "../utils"
import { TransactionBaseService } from "../interfaces"
import { promiseAll } from "@medusajs/utils"
class TaxRateService extends TransactionBaseService { class TaxRateService extends TransactionBaseService {
protected readonly productService_: ProductService protected readonly productService_: ProductService
@@ -49,7 +49,20 @@ class TaxRateService extends TransactionBaseService {
const taxRateRepo = this.activeManager_.withRepository( const taxRateRepo = this.activeManager_.withRepository(
this.taxRateRepository_ this.taxRateRepository_
) )
let q: string | undefined
if (selector.q) {
q = selector.q
delete selector.q
}
const query = buildQuery(selector, config) const query = buildQuery(selector, config)
if (q) {
query.where["name"] = ILike(`%${q}%`)
}
return await taxRateRepo.findWithResolution(query) return await taxRateRepo.findWithResolution(query)
} }
@@ -60,7 +73,20 @@ class TaxRateService extends TransactionBaseService {
const taxRateRepo = this.activeManager_.withRepository( const taxRateRepo = this.activeManager_.withRepository(
this.taxRateRepository_ this.taxRateRepository_
) )
let q: string | undefined
if (selector.q) {
q = selector.q
delete selector.q
}
const query = buildQuery(selector, config) const query = buildQuery(selector, config)
if (q) {
query.where["name"] = ILike(`%${q}%`)
}
return await taxRateRepo.findAndCountWithResolution(query) return await taxRateRepo.findAndCountWithResolution(query)
} }
+1
View File
@@ -12,6 +12,7 @@ export type FilterableTaxRateProps = {
created_at?: Date | DateComparisonOperator created_at?: Date | DateComparisonOperator
updated_at?: Date | DateComparisonOperator updated_at?: Date | DateComparisonOperator
deleted_at?: Date | DateComparisonOperator deleted_at?: Date | DateComparisonOperator
q?: string
} }
export type UpdateTaxRateInput = { export type UpdateTaxRateInput = {