feat(subscription): add admin quota reset actions (#5952)

* feat(subscription): add admin quota reset actions

* fix(subscription): keep quota reset in plan row actions

* refactor(subscription): move user subscription actions into menu
This commit is contained in:
Seefs
2026-07-07 12:47:41 +08:00
committed by GitHub
parent d1abf78ec0
commit 9b93d61b7f
17 changed files with 729 additions and 26 deletions
+25
View File
@@ -24,6 +24,9 @@ import type {
PlanPayload,
UserSubscriptionRecord,
CreateUserSubscriptionRequest,
ResetUserSubscriptionsRequest,
ResetPlanSubscriptionsRequest,
SubscriptionResetResult,
SubscriptionPayResponse,
SubscriptionPayRequest,
SelfSubscriptionData,
@@ -105,6 +108,28 @@ export async function deleteUserSubscription(
return res.data
}
export async function resetUserSubscriptionsByPlan(
userId: number,
data: ResetUserSubscriptionsRequest
): Promise<ApiResponse<SubscriptionResetResult>> {
const res = await api.post(
`/api/subscription/admin/users/${userId}/subscriptions/reset`,
data
)
return res.data
}
export async function resetPlanSubscriptions(
planId: number,
data: ResetPlanSubscriptionsRequest
): Promise<ApiResponse<SubscriptionResetResult>> {
const res = await api.post(
`/api/subscription/admin/plans/${planId}/subscriptions/reset`,
data
)
return res.data
}
// ============================================================================
// User-facing Subscription Payment
// ============================================================================
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Row } from '@tanstack/react-table'
import { Pencil, Power, PowerOff } from 'lucide-react'
import { Pencil, Power, PowerOff, RotateCcw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
@@ -50,6 +50,11 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
setOpen('toggle-status')
}
const handleResetSubscriptions = () => {
setCurrentRow(row.original)
setOpen('reset-subscriptions')
}
return (
<div className='-ml-1.5 flex items-center gap-1'>
<Tooltip>
@@ -69,6 +74,23 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<TooltipContent>{t('Edit')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
disabled={!complianceConfirmed}
onClick={handleResetSubscriptions}
aria-label={t('Reset subscription quota')}
/>
}
>
<RotateCcw />
</TooltipTrigger>
<TooltipContent>{t('Reset subscription quota')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
@@ -0,0 +1,88 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Switch } from '@/components/ui/switch'
import { resetPlanSubscriptions } from '../../api'
import { useSubscriptions } from '../subscriptions-provider'
export function ResetSubscriptionsDialog() {
const { t } = useTranslation()
const { open, setOpen, currentRow, triggerRefresh } = useSubscriptions()
const [advanceResetTime, setAdvanceResetTime] = useState(true)
const [resetting, setResetting] = useState(false)
const isOpen = open === 'reset-subscriptions'
const plan = currentRow?.plan
const planLabel = plan?.title || (plan?.id ? `#${plan.id}` : '-')
useEffect(() => {
if (isOpen) setAdvanceResetTime(true)
}, [isOpen])
const handleConfirm = async () => {
if (!plan?.id) return
setResetting(true)
try {
const res = await resetPlanSubscriptions(plan.id, {
advance_reset_time: advanceResetTime,
})
if (res.success) {
toast.success(
t('Reset {{count}} active subscriptions', {
count: res.data?.reset_count || 0,
})
)
triggerRefresh()
setOpen(null)
}
} catch {
toast.error(t('Operation failed'))
} finally {
setResetting(false)
}
}
return (
<ConfirmDialog
open={isOpen}
onOpenChange={(nextOpen) => !nextOpen && setOpen(null)}
title={t('Reset subscription quota')}
desc={t('Reset all active subscriptions under {{plan}}?', {
plan: planLabel,
})}
confirmText={t('Reset quota')}
handleConfirm={handleConfirm}
disabled={!plan?.id}
isLoading={resetting}
>
<label className='flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm'>
<span>{t('Advance next reset time')}</span>
<Switch
checked={advanceResetTime}
onCheckedChange={(checked) => setAdvanceResetTime(!!checked)}
aria-label={t('Advance next reset time')}
/>
</label>
</ConfirmDialog>
)
}
@@ -16,13 +16,13 @@ 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 { Ban, Plus, RotateCcw, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { DataTableRowActionMenu, StaticDataTable } from '@/components/data-table'
import {
sideDrawerContentClassName,
sideDrawerFormClassName,
@@ -31,6 +31,11 @@ import {
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
} from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
@@ -46,6 +51,7 @@ import {
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { formatQuota } from '@/lib/format'
import {
@@ -54,6 +60,7 @@ import {
createUserSubscription,
invalidateUserSubscription,
deleteUserSubscription,
resetUserSubscriptionsByPlan,
} from '../../api'
import { formatTimestamp } from '../../lib'
import type { PlanRecord, UserSubscriptionRecord } from '../../types'
@@ -73,7 +80,7 @@ function SubscriptionStatusBadge(props: {
const now = Date.now() / 1000
const isExpired = (props.sub.end_time || 0) > 0 && props.sub.end_time < now
const isActive = props.sub.status === 'active' && !isExpired
if (isActive)
if (isActive) {
return (
<StatusBadge
label={props.t('Active')}
@@ -81,7 +88,8 @@ function SubscriptionStatusBadge(props: {
copyable={false}
/>
)
if (props.sub.status === 'cancelled')
}
if (props.sub.status === 'cancelled') {
return (
<StatusBadge
label={props.t('Invalidated')}
@@ -89,6 +97,7 @@ function SubscriptionStatusBadge(props: {
copyable={false}
/>
)
}
return (
<StatusBadge
label={props.t('Expired')}
@@ -105,6 +114,12 @@ export function UserSubscriptionsDialog(props: Props) {
const [plans, setPlans] = useState<PlanRecord[]>([])
const [subs, setSubs] = useState<UserSubscriptionRecord[]>([])
const [selectedPlanId, setSelectedPlanId] = useState<string>('')
const [resetting, setResetting] = useState(false)
const [advanceResetTime, setAdvanceResetTime] = useState(true)
const [resetAction, setResetAction] = useState<{
planId: number
planTitle: string
} | null>(null)
const [confirmAction, setConfirmAction] = useState<{
type: 'invalidate' | 'delete'
subId: number
@@ -190,6 +205,31 @@ export function UserSubscriptionsDialog(props: Props) {
}
}
const handleResetConfirm = async () => {
if (!props.user?.id || !resetAction) return
setResetting(true)
try {
const res = await resetUserSubscriptionsByPlan(props.user.id, {
plan_id: resetAction.planId,
advance_reset_time: advanceResetTime,
})
if (res.success) {
toast.success(
t('Reset {{count}} active subscriptions', {
count: res.data?.reset_count || 0,
})
)
await loadData()
props.onSuccess?.()
}
} catch {
toast.error(t('Operation failed'))
} finally {
setResetting(false)
setResetAction(null)
}
}
return (
<>
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
@@ -204,17 +244,15 @@ export function UserSubscriptionsDialog(props: Props) {
<div className={sideDrawerFormClassName()}>
<div className='flex gap-2'>
<Select
items={[
...plans.map((p) => ({
value: String(p.plan.id),
label: (
<>
{p.plan.title}($
{Number(p.plan.price_amount || 0).toFixed(2)})
</>
),
})),
]}
items={plans.map((p) => ({
value: String(p.plan.id),
label: (
<>
{p.plan.title}($
{Number(p.plan.price_amount || 0).toFixed(2)})
</>
),
}))}
value={selectedPlanId}
onValueChange={(v) => v !== null && setSelectedPlanId(v)}
>
@@ -322,10 +360,25 @@ export function UserSubscriptionsDialog(props: Props) {
const isActive = sub.status === 'active' && !isExpired
return (
<div className='flex justify-end gap-1'>
<Button
size='sm'
variant='outline'
<DataTableRowActionMenu ariaLabel={t('Actions')}>
<DropdownMenuItem
disabled={!isActive}
onClick={() => {
setAdvanceResetTime(true)
setResetAction({
planId: sub.plan_id,
planTitle:
planTitleMap.get(sub.plan_id) ||
`#${sub.plan_id}`,
})
}}
>
{t('Reset quota')}
<DropdownMenuShortcut>
<RotateCcw size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!isActive}
onClick={() =>
setConfirmAction({
@@ -335,9 +388,12 @@ export function UserSubscriptionsDialog(props: Props) {
}
>
{t('Invalidate')}
</Button>
<Button
size='sm'
<DropdownMenuShortcut>
<Ban size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant='destructive'
onClick={() =>
setConfirmAction({
@@ -347,8 +403,11 @@ export function UserSubscriptionsDialog(props: Props) {
}
>
{t('Delete')}
</Button>
</div>
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DataTableRowActionMenu>
)
},
},
@@ -380,6 +439,29 @@ export function UserSubscriptionsDialog(props: Props) {
destructive={confirmAction.type === 'delete'}
/>
)}
{resetAction && (
<ConfirmDialog
open
onOpenChange={(v) => !v && setResetAction(null)}
title={t('Reset subscription quota')}
desc={t('Reset active {{plan}} subscriptions for this user?', {
plan: resetAction.planTitle,
})}
confirmText={t('Reset quota')}
handleConfirm={handleResetConfirm}
isLoading={resetting}
>
<label className='flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-sm'>
<span>{t('Advance next reset time')}</span>
<Switch
checked={advanceResetTime}
onCheckedChange={(checked) => setAdvanceResetTime(!!checked)}
aria-label={t('Advance next reset time')}
/>
</label>
</ConfirmDialog>
)}
</>
)
}
@@ -16,6 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ResetSubscriptionsDialog } from './dialogs/reset-subscriptions-dialog'
import { ToggleStatusDialog } from './dialogs/toggle-status-dialog'
import { SubscriptionsMutateDrawer } from './subscriptions-mutate-drawer'
import { useSubscriptions } from './subscriptions-provider'
@@ -32,6 +33,7 @@ export function SubscriptionsDialogs() {
currentRow={isUpdate ? currentRow || undefined : undefined}
/>
<ToggleStatusDialog />
<ResetSubscriptionsDialog />
</>
)
}
+22 -1
View File
@@ -117,6 +117,23 @@ export interface CreateUserSubscriptionRequest {
plan_id: number
}
export interface ResetUserSubscriptionsRequest {
plan_id: number
advance_reset_time: boolean
}
export interface ResetPlanSubscriptionsRequest {
advance_reset_time: boolean
}
export interface SubscriptionResetResult {
plan_id: number
matched_count: number
reset_count: number
user_count: number
advance_reset_time: boolean
}
// ============================================================================
// Self Subscription Data (user-facing)
// ============================================================================
@@ -131,4 +148,8 @@ export interface SelfSubscriptionData {
// Dialog Types
// ============================================================================
export type SubscriptionsDialogType = 'create' | 'update' | 'toggle-status'
export type SubscriptionsDialogType =
| 'create'
| 'update'
| 'toggle-status'
| 'reset-subscriptions'