fix(redemption): add status filtering and cleanup action

This commit is contained in:
CaIon
2026-07-04 16:15:47 +08:00
parent 86021d8ed2
commit 12603a7765
10 changed files with 272 additions and 130 deletions
+7 -4
View File
@@ -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
}
@@ -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>
)
}
@@ -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')}
/>
</>
)
}
@@ -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
View File
@@ -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) => ({
+1
View File
@@ -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/')({