feat(admin-next) discounts list page (#6490)

This commit is contained in:
Frane Polić
2024-02-27 12:33:19 +01:00
committed by GitHub
parent a223566d96
commit 608c10383a
23 changed files with 683 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
fix(medusa): add `order` param to discounts list
@@ -187,7 +187,14 @@
"domain": "Draft Orders"
},
"discounts": {
"domain": "Discounts"
"domain": "Discounts",
"deleteWarning": "You are about to delete the discount {{title}}. This action cannot be undone.",
"discountStatus": {
"scheduled": "Scheduled",
"expired": "Expired",
"active": "Active",
"disabled": "Disabled"
}
},
"pricing": {
"domain": "Pricing"
@@ -355,6 +362,11 @@
"sales_channels": "Sales Channels",
"status": "Status",
"code": "Code",
"value": "Value",
"disabled": "Disabled",
"dynamic": "Dynamic",
"normal": "Normal",
"totalRedemptions": "Total Redemptions",
"countries": "Countries",
"paymentProviders": "Payment Providers",
"fulfillmentProviders": "Fulfillment Providers",
@@ -392,6 +404,7 @@
"items": "Items",
"salesChannel": "Sales Channel",
"region": "Region",
"discount": "Discount",
"role": "Role",
"sent": "Sent",
"salesChannels": "Sales Channels",
@@ -0,0 +1,28 @@
import type { Discount } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
type DiscountCellProps = {
discount: Discount
}
export const CodeCell = ({ discount }: DiscountCellProps) => {
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
{/*// TODO: border color inversion*/}
<span className="bg-ui-tag-neutral-bg truncate rounded-md border border-neutral-200 p-1 text-xs">
{discount.code}
</span>
</div>
)
}
export const CodeHeader = () => {
const { t } = useTranslation()
return (
<div className=" flex h-full w-full items-center ">
<span>{t("fields.description")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./code-cell"
@@ -0,0 +1,25 @@
import type { Discount } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
type DiscountCellProps = {
discount: Discount
}
export const DescriptionCell = ({ discount }: DiscountCellProps) => {
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
<span className="truncate">{discount.rule.description}</span>
</div>
)
}
export const DescriptionHeader = () => {
const { t } = useTranslation()
return (
<div className=" flex h-full w-full items-center ">
<span>{t("fields.description")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./description-cell"
@@ -0,0 +1 @@
export * from "./redemption-cell.tsx"
@@ -0,0 +1,23 @@
import { useTranslation } from "react-i18next"
type DiscountCellProps = {
redemptions: number
}
export const RedemptionCell = ({ redemptions }: DiscountCellProps) => {
return (
<div className="flex h-full w-full items-center justify-end gap-x-3 text-right">
<span>{redemptions}</span>
</div>
)
}
export const RedemptionHeader = () => {
const { t } = useTranslation()
return (
<div className="flex h-full w-full items-center justify-end text-right">
<span className="truncate">{t("fields.totalRedemptions")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./status-cell"
@@ -0,0 +1,72 @@
import { Discount } from "@medusajs/medusa"
import { end, parse } from "iso8601-duration"
import { StatusCell as StatusCell_ } from "../../common/status-cell"
import { useTranslation } from "react-i18next"
type DiscountCellProps = {
discount: Discount
}
enum PromotionStatus {
SCHEDULED = "SCHEDULED",
EXPIRED = "EXPIRED",
ACTIVE = "ACTIVE",
DISABLED = "DISABLED",
}
const getDiscountStatus = (discount: Discount) => {
if (discount.is_disabled) {
return PromotionStatus.DISABLED
}
const date = new Date()
if (new Date(discount.starts_at) > date) {
return PromotionStatus.SCHEDULED
}
if (
(discount.ends_at && new Date(discount.ends_at) < date) ||
(discount.valid_duration &&
date >
end(parse(discount.valid_duration), new Date(discount.starts_at))) ||
discount.usage_count === discount.usage_limit
) {
return PromotionStatus.EXPIRED
}
return PromotionStatus.ACTIVE
}
export const StatusCell = ({ discount }: DiscountCellProps) => {
const { t } = useTranslation()
const [color, text] = {
[PromotionStatus.DISABLED]: [
"grey",
t("discounts.discountStatus.disabled"),
],
[PromotionStatus.ACTIVE]: ["green", t("discounts.discountStatus.active")],
[PromotionStatus.SCHEDULED]: [
"orange",
t("discounts.discountStatus.scheduled"),
],
[PromotionStatus.EXPIRED]: ["red", t("discounts.discountStatus.expired")],
}[getDiscountStatus(discount)] as [
"grey" | "orange" | "green" | "red",
string
]
return <StatusCell_ color={color}>{text}</StatusCell_>
}
export const StatusHeader = () => {
const { t } = useTranslation()
return (
<div className=" flex h-full w-full items-center ">
<span>{t("fields.status")}</span>
</div>
)
}
@@ -0,0 +1 @@
export * from "./value-cell.tsx"
@@ -0,0 +1,35 @@
import { DiscountRule } from "@medusajs/medusa"
import { useTranslation } from "react-i18next"
import { MoneyAmountCell } from "../../common/money-amount-cell"
type DiscountCellProps = {
rule: DiscountRule
currencyCode: string
}
export const ValueCell = ({ currencyCode, rule }: DiscountCellProps) => {
const isFixed = rule.type === "fixed"
const isPercentage = rule.type === "percentage"
const isFreeShipping = rule.type === "free_shipping"
return (
<div className="flex h-full w-full items-center gap-x-3 overflow-hidden">
{isFreeShipping && <span>Free shipping</span>}
{isPercentage && <span className="">{rule.value}%</span>}
{isFixed && (
<MoneyAmountCell currencyCode={currencyCode} amount={rule.value} />
)}
</div>
)
}
export const ValueHeader = () => {
const { t } = useTranslation()
return (
<div className=" flex h-full w-full items-center ">
<span>{t("fields.value")}</span>
</div>
)
}
@@ -0,0 +1,65 @@
import { useMemo } from "react"
import { ColumnDef, createColumnHelper } from "@tanstack/react-table"
import type { Discount } from "@medusajs/medusa"
import {
CodeCell,
CodeHeader,
} from "../../../components/table/table-cells/discount/code-cell"
import {
DescriptionHeader,
DescriptionCell,
} from "../../../components/table/table-cells/discount/description-cell"
import {
ValueCell,
ValueHeader,
} from "../../../components/table/table-cells/discount/value-cell"
import {
RedemptionCell,
RedemptionHeader,
} from "../../../components/table/table-cells/discount/redemption-cell"
import {
StatusHeader,
StatusCell,
} from "../../../components/table/table-cells/discount/status-cell"
const columnHelper = createColumnHelper<Discount>()
export const useDiscountTableColumns = () => {
return useMemo(
() => [
columnHelper.display({
id: "discount",
header: () => <CodeHeader />,
cell: ({ row }) => <CodeCell discount={row.original} />,
}),
columnHelper.accessor("rule.description", {
header: () => <DescriptionHeader />,
cell: ({ row }) => <DescriptionCell discount={row.original} />,
}),
columnHelper.accessor("rule.value", {
header: () => <ValueHeader />,
cell: ({ row }) => (
<ValueCell
rule={row.original.rule}
currencyCode={row.original.regions[0]?.currency_code}
/>
),
}),
columnHelper.display({
id: "status",
header: () => <StatusHeader />,
cell: ({ row }) => <StatusCell discount={row.original} />,
}),
columnHelper.accessor("usage_count", {
header: () => <RedemptionHeader />,
cell: ({ row }) => (
<RedemptionCell redemptions={row.original.usage_count} />
),
}),
],
[]
) as ColumnDef<Discount>[]
}
@@ -0,0 +1,51 @@
import { useTranslation } from "react-i18next"
import { Filter } from "../../../components/table/data-table"
export const useDiscountTableFilters = () => {
const { t } = useTranslation()
let 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",
}))
const isDisabledFilter: Filter = {
key: "is_disabled",
label: t("fields.disabled"),
type: "select",
options: [
{
label: t("fields.true"),
value: "true",
},
{
label: t("fields.false"),
value: "false",
},
],
}
const isDynamicFilter: Filter = {
key: "is_dynamic",
label: t("fields.type"),
type: "select",
options: [
{
label: t("fields.dynamic"),
value: "true",
},
{
label: t("fields.normal"),
value: "false",
},
],
}
filters = [...filters, isDisabledFilter, isDynamicFilter]
return filters
}
@@ -0,0 +1,45 @@
import { AdminGetDiscountsParams } from "@medusajs/medusa"
import { useQueryParams } from "../../use-query-params"
type UseDiscountTableQueryProps = {
prefix?: string
pageSize?: number
}
export const useDiscountTableQuery = ({
prefix,
pageSize = 20,
}: UseDiscountTableQueryProps) => {
const queryObject = useQueryParams(
[
"offset",
"order",
"q",
"is_dynamic",
"is_disabled",
"created_at",
"updated_at",
],
prefix
)
const { offset, order, q, is_dynamic, is_disabled, created_at, updated_at } =
queryObject
const searchParams: AdminGetDiscountsParams = {
limit: pageSize,
is_disabled: is_disabled ? is_disabled === "true" : undefined,
is_dynamic: is_dynamic ? is_dynamic === "true" : undefined,
created_at: created_at ? JSON.parse(created_at) : undefined,
updated_at: updated_at ? JSON.parse(updated_at) : undefined,
offset: offset ? Number(offset) : 0,
order,
q,
}
return {
searchParams,
raw: queryObject,
}
}
@@ -0,0 +1,145 @@
import { PencilSquare, Trash } from "@medusajs/icons"
import type { Discount } from "@medusajs/medusa"
import { Button, Container, Heading, usePrompt } from "@medusajs/ui"
import { createColumnHelper } from "@tanstack/react-table"
import { useAdminDeleteDiscount, useAdminDiscounts } from "medusa-react"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { Link, Outlet, useLoaderData } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table"
import { useDiscountTableFilters } from "../../../../../hooks/table/filters/use-discount-table-filters"
import { useDiscountTableColumns } from "../../../../../hooks/table/columns/use-discount-table-columns"
import { useDiscountTableQuery } from "../../../../../hooks/table/query/use-discount-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { discountsLoader } from "../../loader"
const PAGE_SIZE = 20
export const DiscountListTable = () => {
const { t } = useTranslation()
const initialData = useLoaderData() as Awaited<
ReturnType<ReturnType<typeof discountsLoader>>
>
const { searchParams, raw } = useDiscountTableQuery({ pageSize: PAGE_SIZE })
const { discounts, count, isLoading, isError, error } = useAdminDiscounts(
{
...searchParams,
},
{
initialData,
keepPreviousData: true,
}
)
const filters = useDiscountTableFilters()
const columns = useColumns()
const { table } = useDataTable({
data: (discounts ?? []) as Discount[],
columns,
count,
enablePagination: true,
pageSize: PAGE_SIZE,
getRowId: (row) => row.id,
})
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("discounts.domain")}</Heading>
<Button size="small" variant="secondary" asChild>
<Link to="create">{t("actions.create")}</Link>
</Button>
</div>
<DataTable
table={table}
columns={columns}
count={count}
pageSize={PAGE_SIZE}
filters={filters}
search
pagination
isLoading={isLoading}
queryObject={raw}
navigateTo={(row) => `${row.original.id}`}
orderBy={["code", "created_at", "updated_at"]}
/>
<Outlet />
</Container>
)
}
const DiscountActions = ({ discount }: { discount: Discount }) => {
const { t } = useTranslation()
const prompt = usePrompt()
const { mutateAsync } = useAdminDeleteDiscount(discount.id)
const handleDelete = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("discounts.deleteWarning", {
title: discount.code,
}),
confirmText: t("actions.delete"),
cancelText: t("actions.cancel"),
})
if (!res) {
return
}
await mutateAsync()
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <PencilSquare />,
label: t("actions.edit"),
to: `/discounts/${discount.id}/edit`,
},
],
},
{
actions: [
{
icon: <Trash />,
label: t("actions.delete"),
onClick: handleDelete,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<Discount>()
const useColumns = () => {
const base = useDiscountTableColumns()
return useMemo(
() => [
...base,
columnHelper.display({
id: "actions",
cell: ({ row }) => {
return <DiscountActions discount={row.original} />
},
}),
],
[base]
)
}
@@ -0,0 +1 @@
export * from "./discount-list-table.tsx"
@@ -1 +1,2 @@
export { DiscountsList as Component } from "./list";
export { discountsLoader } from "./loader"
export { DiscountsList as Component } from "./list"
@@ -1,11 +1,18 @@
import { Container, Heading } from "@medusajs/ui";
import after from "medusa-admin:widgets/discount/list/after"
import before from "medusa-admin:widgets/discount/list/before"
import { DiscountListTable } from "./components/discount-list-table"
export const DiscountsList = () => {
return (
<div>
<Container>
<Heading>Discounts</Heading>
</Container>
<div className="flex flex-col gap-y-2">
{before.widgets.map((w, i) => (
<w.Component key={i} />
))}
<DiscountListTable />
{after.widgets.map((w, i) => (
<w.Component key={i} />
))}
</div>
);
};
)
}
@@ -0,0 +1,24 @@
import { QueryClient } from "@tanstack/react-query"
import { Response } from "@medusajs/medusa-js"
import { AdminDiscountsListRes } from "@medusajs/medusa"
import { adminDiscountKeys } from "medusa-react"
import { medusa, queryClient } from "../../../lib/medusa"
const discountsListQuery = () => ({
queryKey: adminDiscountKeys.list({ limit: 20, offset: 0 }),
queryFn: async () => medusa.admin.discounts.list({ limit: 20, offset: 0 }),
})
export const discountsLoader = (client: QueryClient) => {
return async () => {
const query = discountsListQuery()
return (
queryClient.getQueryData<Response<AdminDiscountsListRes>>(
query.queryKey
) ?? (await client.fetchQuery(query))
)
}
}
@@ -41,4 +41,50 @@ export interface AdminGetDiscountsParams {
* Comma-separated relations that should be expanded in each returned discount.
*/
expand?: string
/**
* A discount field to sort-order the retrieved discounts by.
*/
order?: 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
}
}
@@ -7,7 +7,10 @@ import {
import { Transform, Type } from "class-transformer"
import { AdminGetDiscountsDiscountRuleParams } from "../../../../types/discount"
import { extendedFindParamsMixin } from "../../../../types/common"
import {
DateComparisonOperator,
extendedFindParamsMixin,
} from "../../../../types/common"
import { Request, Response } from "express"
import { DiscountService } from "../../../../services"
import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
@@ -39,6 +42,51 @@ import { optionalBooleanMapper } from "../../../../utils/validators/is-boolean"
* - (query) limit=20 {number} The number of discounts to return
* - (query) offset=0 {number} The number of discounts to skip when retrieving the discounts.
* - (query) expand {string} Comma-separated relations that should be expanded in each returned discount.
* - (query) order {string} A discount field to sort-order the retrieved discounts by.
* - 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
* x-codegen:
* method: list
* queryParams: AdminGetDiscountsParams
@@ -167,4 +215,27 @@ export class AdminGetDiscountsParams extends extendedFindParamsMixin({
@IsOptional()
@Transform(({ value }) => optionalBooleanMapper.get(value))
is_disabled?: boolean
/**
* Date filters to apply on the discounts' `created_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
created_at?: DateComparisonOperator
/**
* Date filters to apply on the discounts' `updated_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
updated_at?: DateComparisonOperator
/**
* The field to sort the data by. By default, the sort order is ascending. To change the order to descending, prefix the field name with `-`.
*/
@IsString()
@IsOptional()
order?: string
}
+11
View File
@@ -18,6 +18,7 @@ import {
import { optionalBooleanMapper } from "../utils/validators/is-boolean"
import { IsType } from "../utils/validators/is-type"
import { ExactlyOne } from "./validators/exactly-one"
import { DateComparisonOperator } from "./common"
export type QuerySelector = {
q?: string
@@ -46,6 +47,16 @@ export class FilterableDiscountProps {
@IsOptional()
@Type(() => AdminGetDiscountsDiscountRuleParams)
rule?: AdminGetDiscountsDiscountRuleParams
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
created_at?: DateComparisonOperator
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
updated_at?: DateComparisonOperator
}
/**