fix(redemption): add status filtering and cleanup action
This commit is contained in:
@@ -29,8 +29,9 @@ func GetAllRedemptions(c *gin.Context) {
|
||||
|
||||
func SearchRedemptions(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
status := c.Query("status")
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
redemptions, total, err := model.SearchRedemptions(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
redemptions, total, err := model.SearchRedemptions(keyword, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
+29
-7
@@ -60,7 +60,7 @@ func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total
|
||||
return redemptions, total, nil
|
||||
}
|
||||
|
||||
func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
|
||||
func SearchRedemptions(keyword string, status string, startIdx int, num int) (redemptions []*Redemption, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return nil, 0, tx.Error
|
||||
@@ -71,14 +71,36 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re
|
||||
}
|
||||
}()
|
||||
|
||||
// Build query based on keyword type
|
||||
query := tx.Model(&Redemption{})
|
||||
|
||||
// Only try to convert to ID if the string represents a valid integer
|
||||
if id, err := strconv.Atoi(keyword); err == nil {
|
||||
query = query.Where("id = ? OR name LIKE ?", id, keyword+"%")
|
||||
} else {
|
||||
query = query.Where("name LIKE ?", keyword+"%")
|
||||
if keyword != "" {
|
||||
if id, err := strconv.Atoi(keyword); err == nil {
|
||||
query = query.Where("id = ? OR name LIKE ?", id, keyword+"%")
|
||||
} else {
|
||||
query = query.Where("name LIKE ?", keyword+"%")
|
||||
}
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
now := common.GetTimestamp()
|
||||
switch status {
|
||||
case "expired":
|
||||
query = query.Where(
|
||||
"status = ? AND expired_time != 0 AND expired_time < ?",
|
||||
common.RedemptionCodeStatusEnabled,
|
||||
now,
|
||||
)
|
||||
case strconv.Itoa(common.RedemptionCodeStatusEnabled):
|
||||
query = query.Where(
|
||||
"status = ? AND (expired_time = 0 OR expired_time >= ?)",
|
||||
common.RedemptionCodeStatusEnabled,
|
||||
now,
|
||||
)
|
||||
case strconv.Itoa(common.RedemptionCodeStatusDisabled):
|
||||
query = query.Where("status = ?", common.RedemptionCodeStatusDisabled)
|
||||
case strconv.Itoa(common.RedemptionCodeStatusUsed):
|
||||
query = query.Where("status = ?", common.RedemptionCodeStatusUsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSearchRedemptionsFiltersAndPaginates(t *testing.T) {
|
||||
require.NoError(t, DB.AutoMigrate(&Redemption{}))
|
||||
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, DB.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&Redemption{}).Error)
|
||||
})
|
||||
|
||||
now := common.GetTimestamp()
|
||||
redemptions := []Redemption{
|
||||
{Id: 1, Name: "alpha-active", Key: "00000000000000000000000000000001", Status: common.RedemptionCodeStatusEnabled, ExpiredTime: 0},
|
||||
{Id: 2, Name: "alpha-future", Key: "00000000000000000000000000000002", Status: common.RedemptionCodeStatusEnabled, ExpiredTime: now + 3600},
|
||||
{Id: 3, Name: "alpha-expired", Key: "00000000000000000000000000000003", Status: common.RedemptionCodeStatusEnabled, ExpiredTime: now - 10},
|
||||
{Id: 4, Name: "beta-disabled", Key: "00000000000000000000000000000004", Status: common.RedemptionCodeStatusDisabled, ExpiredTime: 0},
|
||||
{Id: 5, Name: "beta-used", Key: "00000000000000000000000000000005", Status: common.RedemptionCodeStatusUsed, ExpiredTime: 0},
|
||||
}
|
||||
require.NoError(t, DB.Create(&redemptions).Error)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
keyword string
|
||||
status string
|
||||
startIdx int
|
||||
num int
|
||||
wantTotal int64
|
||||
wantIds []int
|
||||
}{
|
||||
{
|
||||
name: "no filters returns all rows",
|
||||
num: 10,
|
||||
wantTotal: 5,
|
||||
wantIds: []int{5, 4, 3, 2, 1},
|
||||
},
|
||||
{
|
||||
name: "keyword filters by name prefix",
|
||||
keyword: "alpha",
|
||||
num: 10,
|
||||
wantTotal: 3,
|
||||
wantIds: []int{3, 2, 1},
|
||||
},
|
||||
{
|
||||
name: "enabled status excludes expired rows",
|
||||
status: "1",
|
||||
num: 10,
|
||||
wantTotal: 2,
|
||||
wantIds: []int{2, 1},
|
||||
},
|
||||
{
|
||||
name: "expired status returns enabled expired rows",
|
||||
status: "expired",
|
||||
num: 10,
|
||||
wantTotal: 1,
|
||||
wantIds: []int{3},
|
||||
},
|
||||
{
|
||||
name: "disabled status",
|
||||
status: "2",
|
||||
num: 10,
|
||||
wantTotal: 1,
|
||||
wantIds: []int{4},
|
||||
},
|
||||
{
|
||||
name: "used status",
|
||||
status: "3",
|
||||
num: 10,
|
||||
wantTotal: 1,
|
||||
wantIds: []int{5},
|
||||
},
|
||||
{
|
||||
name: "pagination keeps unpaged total",
|
||||
startIdx: 1,
|
||||
num: 2,
|
||||
wantTotal: 5,
|
||||
wantIds: []int{4, 3},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rows, total, err := SearchRedemptions(tt.keyword, tt.status, tt.startIdx, tt.num)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantTotal, total)
|
||||
gotIds := make([]int, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
gotIds = append(gotIds, row.Id)
|
||||
}
|
||||
assert.Equal(t, tt.wantIds, gotIds)
|
||||
})
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -44,10 +44,13 @@ export async function getRedemptions(
|
||||
export async function searchRedemptions(
|
||||
params: SearchRedemptionsParams
|
||||
): Promise<GetRedemptionsResponse> {
|
||||
const { keyword = '', p = 1, page_size = 10 } = params
|
||||
const res = await api.get(
|
||||
`/api/redemption/search?keyword=${keyword}&p=${p}&page_size=${page_size}`
|
||||
)
|
||||
const { keyword = '', status = '', p = 1, page_size = 10 } = params
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.set('keyword', keyword)
|
||||
if (status) queryParams.set('status', status)
|
||||
queryParams.set('p', String(p))
|
||||
queryParams.set('page_size', String(page_size))
|
||||
const res = await api.get(`/api/redemption/search?${queryParams.toString()}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
|
||||
+14
-94
@@ -16,25 +16,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
import { deleteInvalidRedemptions } from '../api'
|
||||
import { type Redemption } from '../types'
|
||||
import { useRedemptions } from './redemptions-provider'
|
||||
import type { Redemption } from '../types'
|
||||
|
||||
type DataTableBulkActionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
@@ -44,11 +33,7 @@ export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
}: DataTableBulkActionsProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const { triggerRefresh } = useRedemptions()
|
||||
const [showDeleteInvalidConfirm, setShowDeleteInvalidConfirm] =
|
||||
useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
|
||||
const contentToCopy = useMemo(() => {
|
||||
const selectedCodes = selectedRows.map((row) => {
|
||||
@@ -58,82 +43,17 @@ export function DataTableBulkActions<TData>({
|
||||
return selectedCodes.join('\n')
|
||||
}, [selectedRows])
|
||||
|
||||
const handleDeleteInvalid = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const result = await deleteInvalidRedemptions()
|
||||
|
||||
if (result.success) {
|
||||
const count = result.data || 0
|
||||
toast.success(
|
||||
t('Successfully deleted {{count}} invalid redemption codes', {
|
||||
count,
|
||||
})
|
||||
)
|
||||
table.resetRowSelection()
|
||||
triggerRefresh()
|
||||
setShowDeleteInvalidConfirm(false)
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BulkActionsToolbar table={table} entityName={t('redemption code')}>
|
||||
<CopyButton
|
||||
value={contentToCopy}
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
tooltip={t('Copy selected codes')}
|
||||
successTooltip={t('Codes copied!')}
|
||||
aria-label={t('Copy selected codes')}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteInvalidConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label={t('Delete invalid redemption codes')}
|
||||
title={t('Delete invalid redemption codes')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>{t('Delete invalid codes')}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('Delete invalid codes (used/disabled/expired)')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</BulkActionsToolbar>
|
||||
|
||||
<ConfirmDialog
|
||||
destructive
|
||||
open={showDeleteInvalidConfirm}
|
||||
onOpenChange={setShowDeleteInvalidConfirm}
|
||||
handleConfirm={handleDeleteInvalid}
|
||||
isLoading={isDeleting}
|
||||
className='max-w-md'
|
||||
title={t('Delete Invalid Redemption Codes?')}
|
||||
desc={
|
||||
<>
|
||||
{t('This will delete all')} <strong>{t('used')}</strong>,{' '}
|
||||
<strong>{t('disabled')}</strong>
|
||||
{t(', and')} <strong>{t('expired')}</strong>{' '}
|
||||
{t('redemption codes.')}
|
||||
<br />
|
||||
{t('This action cannot be undone.')}
|
||||
</>
|
||||
}
|
||||
confirmText={t('Delete Invalid')}
|
||||
<BulkActionsToolbar table={table} entityName={t('redemption code')}>
|
||||
<CopyButton
|
||||
value={contentToCopy}
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
tooltip={t('Copy selected codes')}
|
||||
successTooltip={t('Codes copied!')}
|
||||
aria-label={t('Copy selected codes')}
|
||||
/>
|
||||
</>
|
||||
</BulkActionsToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
+69
-8
@@ -16,22 +16,83 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { deleteInvalidRedemptions } from '../api'
|
||||
import { ERROR_MESSAGES } from '../constants'
|
||||
import { useRedemptions } from './redemptions-provider'
|
||||
|
||||
export function RedemptionsPrimaryButtons() {
|
||||
const { t } = useTranslation()
|
||||
const { setOpen } = useRedemptions()
|
||||
const { setOpen, triggerRefresh } = useRedemptions()
|
||||
const [showDeleteInvalidConfirm, setShowDeleteInvalidConfirm] =
|
||||
useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDeleteInvalid = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const result = await deleteInvalidRedemptions()
|
||||
if (result.success) {
|
||||
const count = result.data || 0
|
||||
toast.success(
|
||||
t('Successfully deleted {{count}} invalid redemption codes', {
|
||||
count,
|
||||
})
|
||||
)
|
||||
triggerRefresh()
|
||||
setShowDeleteInvalidConfirm(false)
|
||||
} else {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.DELETE_INVALID_FAILED))
|
||||
}
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' onClick={() => setOpen('create')}>
|
||||
<Plus className='h-4 w-4' />
|
||||
{t('Create Code')}
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => setShowDeleteInvalidConfirm(true)}
|
||||
>
|
||||
<Trash2 className='text-destructive h-4 w-4' />
|
||||
{t('Delete Invalid')}
|
||||
</Button>
|
||||
<Button size='sm' onClick={() => setOpen('create')}>
|
||||
<Plus className='h-4 w-4' />
|
||||
{t('Create Code')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
destructive
|
||||
open={showDeleteInvalidConfirm}
|
||||
onOpenChange={setShowDeleteInvalidConfirm}
|
||||
handleConfirm={handleDeleteInvalid}
|
||||
isLoading={isDeleting}
|
||||
className='max-w-md'
|
||||
title={t('Delete Invalid Redemption Codes?')}
|
||||
desc={
|
||||
<>
|
||||
{t('This will delete all')} <strong>{t('used')}</strong>,{' '}
|
||||
<strong>{t('disabled')}</strong>
|
||||
{t(', and')} <strong>{t('expired')}</strong>{' '}
|
||||
{t('redemption codes.')}
|
||||
<br />
|
||||
{t('This action cannot be undone.')}
|
||||
</>
|
||||
}
|
||||
confirmText={t('Delete Invalid')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+39
-12
@@ -20,6 +20,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
@@ -31,7 +32,11 @@ import { useMediaQuery } from '@/hooks'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
|
||||
import { getRedemptions, searchRedemptions } from '../api'
|
||||
import { REDEMPTION_STATUS, getRedemptionStatusOptions } from '../constants'
|
||||
import {
|
||||
ERROR_MESSAGES,
|
||||
REDEMPTION_STATUS,
|
||||
getRedemptionStatusOptions,
|
||||
} from '../constants'
|
||||
import { isRedemptionExpired } from '../lib'
|
||||
import type { Redemption } from '../types'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
@@ -68,6 +73,11 @@ export function RedemptionsTable() {
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [{ columnId: 'status', searchKey: 'status', type: 'array' }],
|
||||
})
|
||||
const statusFilter =
|
||||
(columnFilters.find((filter) => filter.id === 'status')?.value as
|
||||
| string[]
|
||||
| undefined) ?? []
|
||||
const statusFilterValue = statusFilter[0] ?? ''
|
||||
|
||||
// Fetch data with React Query
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
@@ -76,18 +86,37 @@ export function RedemptionsTable() {
|
||||
pagination.pageIndex + 1,
|
||||
pagination.pageSize,
|
||||
globalFilter,
|
||||
statusFilterValue,
|
||||
refreshTrigger,
|
||||
],
|
||||
queryFn: async () => {
|
||||
const hasFilter = globalFilter?.trim()
|
||||
const hasStatusFilter = statusFilterValue !== ''
|
||||
const params = {
|
||||
p: pagination.pageIndex + 1,
|
||||
page_size: pagination.pageSize,
|
||||
}
|
||||
|
||||
const result = hasFilter
|
||||
? await searchRedemptions({ ...params, keyword: globalFilter })
|
||||
: await getRedemptions(params)
|
||||
const result =
|
||||
hasFilter || hasStatusFilter
|
||||
? await searchRedemptions({
|
||||
...params,
|
||||
keyword: globalFilter,
|
||||
status: statusFilterValue,
|
||||
})
|
||||
: await getRedemptions(params)
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(
|
||||
result.message ||
|
||||
t(
|
||||
hasFilter || hasStatusFilter
|
||||
? ERROR_MESSAGES.SEARCH_FAILED
|
||||
: ERROR_MESSAGES.LOAD_FAILED
|
||||
)
|
||||
)
|
||||
return { items: [], total: 0 }
|
||||
}
|
||||
|
||||
return {
|
||||
items: result.data?.items || [],
|
||||
@@ -116,7 +145,8 @@ export function RedemptionsTable() {
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
manualPagination: !globalFilter,
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
totalCount: data?.total || 0,
|
||||
ensurePageInRange,
|
||||
})
|
||||
@@ -149,13 +179,10 @@ export function RedemptionsTable() {
|
||||
},
|
||||
],
|
||||
}}
|
||||
getRowClassName={(row, { isMobile }) =>
|
||||
isDisabledRedemptionRow(row.original)
|
||||
? isMobile
|
||||
? DISABLED_ROW_MOBILE
|
||||
: DISABLED_ROW_DESKTOP
|
||||
: undefined
|
||||
}
|
||||
getRowClassName={(row, { isMobile }) => {
|
||||
if (!isDisabledRedemptionRow(row.original)) return undefined
|
||||
return isMobile ? DISABLED_ROW_MOBILE : DISABLED_ROW_DESKTOP
|
||||
}}
|
||||
bulkActions={<DataTableBulkActions table={table} />}
|
||||
/>
|
||||
)
|
||||
|
||||
+9
-2
@@ -16,9 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { type TFunction } from 'i18next'
|
||||
import type { TFunction } from 'i18next'
|
||||
|
||||
import { type StatusBadgeProps } from '@/components/status-badge'
|
||||
import type { StatusBadgeProps } from '@/components/status-badge'
|
||||
|
||||
// ============================================================================
|
||||
// Redemption Status Configuration
|
||||
@@ -63,6 +63,13 @@ export const REDEMPTION_STATUSES: Record<
|
||||
// Note: "Expired" is not a real DB status, it's computed from expired_time
|
||||
export const REDEMPTION_FILTER_EXPIRED = 'expired'
|
||||
|
||||
export const REDEMPTION_FILTER_VALUES = [
|
||||
String(REDEMPTION_STATUS.ENABLED),
|
||||
String(REDEMPTION_STATUS.DISABLED),
|
||||
String(REDEMPTION_STATUS.USED),
|
||||
REDEMPTION_FILTER_EXPIRED,
|
||||
] as const
|
||||
|
||||
export function getRedemptionStatusOptions(t: TFunction) {
|
||||
return [
|
||||
...Object.values(REDEMPTION_STATUSES).map((config) => ({
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface GetRedemptionsResponse {
|
||||
|
||||
export interface SearchRedemptionsParams {
|
||||
keyword?: string
|
||||
status?: string
|
||||
p?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import z from 'zod'
|
||||
|
||||
import { Redemptions } from '@/features/redemption-codes'
|
||||
import { REDEMPTION_STATUS_VALUES } from '@/features/redemption-codes/constants'
|
||||
import { REDEMPTION_FILTER_VALUES } from '@/features/redemption-codes/constants'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
@@ -28,7 +28,7 @@ const redemptionsSearchSchema = z.object({
|
||||
page: z.number().optional().catch(1),
|
||||
pageSize: z.number().optional().catch(10),
|
||||
filter: z.string().optional().catch(''),
|
||||
status: z.array(z.enum(REDEMPTION_STATUS_VALUES)).optional().catch([]),
|
||||
status: z.array(z.enum(REDEMPTION_FILTER_VALUES)).optional().catch([]),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/redemption-codes/')({
|
||||
|
||||
Reference in New Issue
Block a user