diff --git a/controller/audit.go b/controller/audit.go index cbc23184..36080724 100644 --- a/controller/audit.go +++ b/controller/audit.go @@ -46,6 +46,9 @@ var auditContentTemplates = map[string]string{ "channel.upstream_apply_all": "Applied upstream model changes to ${count} channels", "redemption.create": "Created ${count} redemption codes named ${name} (${quota} each)", + + "subscription.plan_reset": "Reset active subscriptions for plan ${plan_id}", + "subscription.user_plan_reset": "Reset active plan ${plan_id} subscriptions for user ${target_user_id}", } // auditContentEN 按 action 模板渲染英文兜底文本;未登记的 action 退回 action 本身。 diff --git a/controller/subscription.go b/controller/subscription.go index 7007ce45..22cee9d5 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "strconv" "strings" @@ -394,6 +395,28 @@ type AdminCreateUserSubscriptionRequest struct { PlanId int `json:"plan_id"` } +type AdminResetSubscriptionRequest struct { + PlanId int `json:"plan_id"` + AdvanceResetTime *bool `json:"advance_reset_time"` +} + +func resolveAdvanceResetTime(value *bool) bool { + if value == nil { + return true + } + return *value +} + +func recordSubscriptionResetUserLogs(result *model.SubscriptionResetResult, adminInfo map[string]interface{}) { + if result == nil || result.ResetCount == 0 { + return + } + content := fmt.Sprintf("管理员重置订阅套餐 %s(ID: %d)额度", result.PlanTitle, result.PlanId) + for _, userId := range result.AffectedUserIds { + model.RecordLogWithAdminInfo(userId, model.LogTypeManage, content, adminInfo) + } +} + // AdminCreateUserSubscription creates a new user subscription from a plan (no payment). func AdminCreateUserSubscription(c *gin.Context) { if !requirePaymentCompliance(c) { @@ -422,6 +445,69 @@ func AdminCreateUserSubscription(c *gin.Context) { common.ApiSuccess(c, nil) } +func AdminResetUserSubscriptionsByPlan(c *gin.Context) { + userId, _ := strconv.Atoi(c.Param("id")) + if userId <= 0 { + common.ApiErrorMsg(c, "无效的用户ID") + return + } + var req AdminResetSubscriptionRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + if req.PlanId <= 0 { + common.ApiErrorMsg(c, "参数错误") + return + } + advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime) + result, err := model.AdminResetUserSubscriptionsByPlan(userId, req.PlanId, advanceResetTime) + if err != nil { + common.ApiError(c, err) + return + } + recordSubscriptionResetUserLogs(result, auditOperatorInfo(c)) + recordManageAuditFor(c, userId, "subscription.user_plan_reset", map[string]interface{}{ + "target_user_id": userId, + "plan_id": result.PlanId, + "plan_title": result.PlanTitle, + "reset_count": result.ResetCount, + "user_count": result.UserCount, + "advance_reset_time": result.AdvanceResetTime, + }) + common.ApiSuccess(c, result) +} + +func AdminResetPlanSubscriptions(c *gin.Context) { + planId, _ := strconv.Atoi(c.Param("id")) + if planId <= 0 { + common.ApiErrorMsg(c, "无效的ID") + return + } + var req AdminResetSubscriptionRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiErrorMsg(c, "参数错误") + return + } + advanceResetTime := resolveAdvanceResetTime(req.AdvanceResetTime) + result, err := model.AdminResetPlanSubscriptions(planId, advanceResetTime) + if err != nil { + common.ApiError(c, err) + return + } + recordSubscriptionResetUserLogs(result, auditOperatorInfo(c)) + common.SysLog(fmt.Sprintf("admin reset subscription plan %d quota: reset_count=%d user_count=%d advance_reset_time=%t", + result.PlanId, result.ResetCount, result.UserCount, result.AdvanceResetTime)) + recordManageAudit(c, "subscription.plan_reset", map[string]interface{}{ + "plan_id": result.PlanId, + "plan_title": result.PlanTitle, + "reset_count": result.ResetCount, + "user_count": result.UserCount, + "advance_reset_time": result.AdvanceResetTime, + }) + common.ApiSuccess(c, result) +} + // AdminInvalidateUserSubscription cancels a user subscription immediately. func AdminInvalidateUserSubscription(c *gin.Context) { subId, _ := strconv.Atoi(c.Param("id")) diff --git a/model/subscription.go b/model/subscription.go index e5f51823..b8ae9774 100644 --- a/model/subscription.go +++ b/model/subscription.go @@ -296,6 +296,16 @@ type SubscriptionSummary struct { Subscription *UserSubscription `json:"subscription"` } +type SubscriptionResetResult struct { + PlanId int `json:"plan_id"` + MatchedCount int `json:"matched_count"` + ResetCount int `json:"reset_count"` + UserCount int `json:"user_count"` + AdvanceResetTime bool `json:"advance_reset_time"` + PlanTitle string `json:"-"` + AffectedUserIds []int `json:"-"` +} + func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) { if plan == nil { return 0, errors.New("plan is nil") @@ -974,6 +984,125 @@ func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) { return "", nil } +func resetUserSubscriptionTx(tx *gorm.DB, sub *UserSubscription, plan *SubscriptionPlan, now int64, advanceResetTime bool) error { + if tx == nil || sub == nil || plan == nil { + return errors.New("invalid reset args") + } + sub.AmountUsed = 0 + if advanceResetTime { + nextReset := calcNextResetTime(time.Unix(now, 0), plan, sub.EndTime) + sub.NextResetTime = nextReset + if nextReset > 0 { + sub.LastResetTime = now + } else { + sub.LastResetTime = 0 + } + } + return tx.Save(sub).Error +} + +func buildSubscriptionResetResult(plan *SubscriptionPlan, subs []UserSubscription, advanceResetTime bool) *SubscriptionResetResult { + userIds := make([]int, 0, len(subs)) + seenUsers := make(map[int]struct{}, len(subs)) + for _, sub := range subs { + if _, ok := seenUsers[sub.UserId]; ok { + continue + } + seenUsers[sub.UserId] = struct{}{} + userIds = append(userIds, sub.UserId) + } + return &SubscriptionResetResult{ + PlanId: plan.Id, + MatchedCount: len(subs), + ResetCount: len(subs), + UserCount: len(userIds), + AdvanceResetTime: advanceResetTime, + PlanTitle: plan.Title, + AffectedUserIds: userIds, + } +} + +func adminResetUserSubscriptionsByPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, now int64, advanceResetTime bool) (*SubscriptionResetResult, error) { + if tx == nil || plan == nil { + return nil, errors.New("invalid reset args") + } + var subs []UserSubscription + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("user_id = ? AND plan_id = ? AND status = ? AND end_time > ?", userId, plan.Id, "active", now). + Order("end_time asc, id asc"). + Find(&subs).Error; err != nil { + return nil, err + } + if len(subs) == 0 { + return nil, errors.New("该用户没有有效的此套餐订阅") + } + for i := range subs { + if err := resetUserSubscriptionTx(tx, &subs[i], plan, now, advanceResetTime); err != nil { + return nil, err + } + } + return buildSubscriptionResetResult(plan, subs, advanceResetTime), nil +} + +func adminResetPlanSubscriptionsTx(tx *gorm.DB, plan *SubscriptionPlan, now int64, advanceResetTime bool) (*SubscriptionResetResult, error) { + if tx == nil || plan == nil { + return nil, errors.New("invalid reset args") + } + var subs []UserSubscription + if err := tx.Set("gorm:query_option", "FOR UPDATE"). + Where("plan_id = ? AND status = ? AND end_time > ?", plan.Id, "active", now). + Order("user_id asc, end_time asc, id asc"). + Find(&subs).Error; err != nil { + return nil, err + } + for i := range subs { + if err := resetUserSubscriptionTx(tx, &subs[i], plan, now, advanceResetTime); err != nil { + return nil, err + } + } + return buildSubscriptionResetResult(plan, subs, advanceResetTime), nil +} + +func AdminResetUserSubscriptionsByPlan(userId int, planId int, advanceResetTime bool) (*SubscriptionResetResult, error) { + if userId <= 0 || planId <= 0 { + return nil, errors.New("invalid userId or planId") + } + var result *SubscriptionResetResult + now := GetDBTimestamp() + err := DB.Transaction(func(tx *gorm.DB) error { + plan, err := getSubscriptionPlanByIdTx(tx, planId) + if err != nil { + return err + } + result, err = adminResetUserSubscriptionsByPlanTx(tx, userId, plan, now, advanceResetTime) + return err + }) + if err != nil { + return nil, err + } + return result, nil +} + +func AdminResetPlanSubscriptions(planId int, advanceResetTime bool) (*SubscriptionResetResult, error) { + if planId <= 0 { + return nil, errors.New("invalid planId") + } + var result *SubscriptionResetResult + now := GetDBTimestamp() + err := DB.Transaction(func(tx *gorm.DB) error { + plan, err := getSubscriptionPlanByIdTx(tx, planId) + if err != nil { + return err + } + result, err = adminResetPlanSubscriptionsTx(tx, plan, now, advanceResetTime) + return err + }) + if err != nil { + return nil, err + } + return result, nil +} + type SubscriptionPreConsumeResult struct { UserSubscriptionId int PreConsumed int64 diff --git a/model/subscription_reset_test.go b/model/subscription_reset_test.go new file mode 100644 index 00000000..338f18a7 --- /dev/null +++ b/model/subscription_reset_test.go @@ -0,0 +1,201 @@ +package model + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func seedSubscriptionResetPlan(t *testing.T, plan *SubscriptionPlan) { + t.Helper() + require.NoError(t, DB.Create(plan).Error) +} + +func seedSubscriptionResetSub(t *testing.T, sub *UserSubscription) { + t.Helper() + require.NoError(t, DB.Create(sub).Error) +} + +func getSubscriptionResetSub(t *testing.T, id int) UserSubscription { + t.Helper() + var sub UserSubscription + require.NoError(t, DB.Where("id = ?", id).First(&sub).Error) + return sub +} + +func TestAdminResetUserSubscriptionsByPlanResetsAllActiveMatchesAndAdvancesTime(t *testing.T) { + truncateTables(t) + + now := GetDBTimestamp() + plan := &SubscriptionPlan{ + Id: 9101, + Title: "Pro", + PriceAmount: 10, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 1000, + QuotaResetPeriod: SubscriptionResetDaily, + } + otherPlan := &SubscriptionPlan{ + Id: 9102, + Title: "Basic", + PriceAmount: 1, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 100, + QuotaResetPeriod: SubscriptionResetDaily, + } + seedSubscriptionResetPlan(t, plan) + seedSubscriptionResetPlan(t, otherPlan) + + activeEnd := now + 30*24*3600 + expiredEnd := now - 1 + seedSubscriptionResetSub(t, &UserSubscription{Id: 9201, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 300, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9202, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 500, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9203, UserId: 101, PlanId: otherPlan.Id, AmountTotal: 100, AmountUsed: 60, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9204, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 700, StartTime: now - 7200, EndTime: expiredEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now - 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9205, UserId: 102, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 800, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 120}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9206, UserId: 101, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 900, StartTime: now - 3600, EndTime: activeEnd, Status: "cancelled", LastResetTime: now - 3600, NextResetTime: now + 120}) + + beforeReset := GetDBTimestamp() + result, err := AdminResetUserSubscriptionsByPlan(101, plan.Id, true) + afterReset := GetDBTimestamp() + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, plan.Id, result.PlanId) + assert.Equal(t, 2, result.MatchedCount) + assert.Equal(t, 2, result.ResetCount) + assert.Equal(t, 1, result.UserCount) + assert.Equal(t, []int{101}, result.AffectedUserIds) + assert.True(t, result.AdvanceResetTime) + + for _, id := range []int{9201, 9202} { + sub := getSubscriptionResetSub(t, id) + assert.Zero(t, sub.AmountUsed) + assert.GreaterOrEqual(t, sub.LastResetTime, beforeReset) + assert.LessOrEqual(t, sub.LastResetTime, afterReset) + assert.Equal(t, calcNextResetTime(time.Unix(sub.LastResetTime, 0), plan, sub.EndTime), sub.NextResetTime) + } + assert.EqualValues(t, 60, getSubscriptionResetSub(t, 9203).AmountUsed) + assert.EqualValues(t, 700, getSubscriptionResetSub(t, 9204).AmountUsed) + assert.EqualValues(t, 800, getSubscriptionResetSub(t, 9205).AmountUsed) + assert.EqualValues(t, 900, getSubscriptionResetSub(t, 9206).AmountUsed) +} + +func TestAdminResetUserSubscriptionsByPlanKeepsResetTimes(t *testing.T) { + truncateTables(t) + + now := GetDBTimestamp() + plan := &SubscriptionPlan{ + Id: 9301, + Title: "Team", + PriceAmount: 20, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 2000, + QuotaResetPeriod: SubscriptionResetMonthly, + } + seedSubscriptionResetPlan(t, plan) + + lastReset := now - 86400 + nextReset := now + 86400 + seedSubscriptionResetSub(t, &UserSubscription{Id: 9302, UserId: 201, PlanId: plan.Id, AmountTotal: 2000, AmountUsed: 1200, StartTime: now - 172800, EndTime: now + 30*24*3600, Status: "active", LastResetTime: lastReset, NextResetTime: nextReset}) + + result, err := AdminResetUserSubscriptionsByPlan(201, plan.Id, false) + + require.NoError(t, err) + assert.False(t, result.AdvanceResetTime) + sub := getSubscriptionResetSub(t, 9302) + assert.Zero(t, sub.AmountUsed) + assert.Equal(t, lastReset, sub.LastResetTime) + assert.Equal(t, nextReset, sub.NextResetTime) +} + +func TestAdminResetUserSubscriptionsByPlanNoActiveMatchReturnsError(t *testing.T) { + truncateTables(t) + + now := GetDBTimestamp() + plan := &SubscriptionPlan{ + Id: 9401, + Title: "Expired", + PriceAmount: 10, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 1000, + } + seedSubscriptionResetPlan(t, plan) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9402, UserId: 301, PlanId: plan.Id, AmountTotal: 1000, AmountUsed: 500, StartTime: now - 7200, EndTime: now - 1, Status: "active"}) + + result, err := AdminResetUserSubscriptionsByPlan(301, plan.Id, true) + + require.Error(t, err) + assert.Nil(t, result) + assert.True(t, strings.Contains(err.Error(), "该用户没有有效的此套餐订阅")) +} + +func TestAdminResetPlanSubscriptionsResetsAllActiveUsers(t *testing.T) { + truncateTables(t) + + now := GetDBTimestamp() + plan := &SubscriptionPlan{ + Id: 9501, + Title: "Business", + PriceAmount: 30, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 3000, + QuotaResetPeriod: SubscriptionResetNever, + } + seedSubscriptionResetPlan(t, plan) + + activeEnd := now + 30*24*3600 + seedSubscriptionResetSub(t, &UserSubscription{Id: 9502, UserId: 401, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1000, StartTime: now - 3600, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9503, UserId: 401, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1100, StartTime: now - 3500, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9504, UserId: 402, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1200, StartTime: now - 3400, EndTime: activeEnd, Status: "active", LastResetTime: now - 3600, NextResetTime: now + 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9505, UserId: 403, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1300, StartTime: now - 7200, EndTime: now - 1, Status: "active", LastResetTime: now - 3600, NextResetTime: now - 10}) + seedSubscriptionResetSub(t, &UserSubscription{Id: 9506, UserId: 404, PlanId: plan.Id, AmountTotal: 3000, AmountUsed: 1400, StartTime: now - 3600, EndTime: activeEnd, Status: "cancelled", LastResetTime: now - 3600, NextResetTime: now + 10}) + + result, err := AdminResetPlanSubscriptions(plan.Id, true) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 3, result.MatchedCount) + assert.Equal(t, 3, result.ResetCount) + assert.Equal(t, 2, result.UserCount) + assert.Equal(t, []int{401, 402}, result.AffectedUserIds) + for _, id := range []int{9502, 9503, 9504} { + sub := getSubscriptionResetSub(t, id) + assert.Zero(t, sub.AmountUsed) + assert.Zero(t, sub.LastResetTime) + assert.Zero(t, sub.NextResetTime) + } + assert.EqualValues(t, 1300, getSubscriptionResetSub(t, 9505).AmountUsed) + assert.EqualValues(t, 1400, getSubscriptionResetSub(t, 9506).AmountUsed) +} + +func TestAdminResetPlanSubscriptionsNoMatchSucceeds(t *testing.T) { + truncateTables(t) + + plan := &SubscriptionPlan{ + Id: 9601, + Title: "Empty", + PriceAmount: 10, + DurationUnit: SubscriptionDurationMonth, + DurationValue: 1, + TotalAmount: 1000, + } + seedSubscriptionResetPlan(t, plan) + + result, err := AdminResetPlanSubscriptions(plan.Id, true) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Zero(t, result.MatchedCount) + assert.Zero(t, result.ResetCount) + assert.Zero(t, result.UserCount) + assert.Empty(t, result.AffectedUserIds) +} diff --git a/router/api-router.go b/router/api-router.go index 86726eed..e11b7488 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -168,10 +168,12 @@ func SetApiRouter(router *gin.Engine) { subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan) subscriptionAdminRoute.PATCH("/plans/:id", controller.AdminUpdateSubscriptionPlanStatus) subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription) + subscriptionAdminRoute.POST("/plans/:id/subscriptions/reset", controller.AdminResetPlanSubscriptions) // User subscription management (admin) subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions) subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription) + subscriptionAdminRoute.POST("/users/:id/subscriptions/reset", controller.AdminResetUserSubscriptionsByPlan) subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription) subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription) } diff --git a/web/default/src/features/subscriptions/api.ts b/web/default/src/features/subscriptions/api.ts index 21da6776..30522544 100644 --- a/web/default/src/features/subscriptions/api.ts +++ b/web/default/src/features/subscriptions/api.ts @@ -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> { + 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> { + const res = await api.post( + `/api/subscription/admin/plans/${planId}/subscriptions/reset`, + data + ) + return res.data +} + // ============================================================================ // User-facing Subscription Payment // ============================================================================ diff --git a/web/default/src/features/subscriptions/components/data-table-row-actions.tsx b/web/default/src/features/subscriptions/components/data-table-row-actions.tsx index a37ee38c..ac5c892f 100644 --- a/web/default/src/features/subscriptions/components/data-table-row-actions.tsx +++ b/web/default/src/features/subscriptions/components/data-table-row-actions.tsx @@ -17,7 +17,7 @@ along with this program. If not, see . 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 (
@@ -69,6 +74,23 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) { {t('Edit')} + + + } + > + + + {t('Reset subscription quota')} + + . + +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 ( + !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} + > + + + ) +} diff --git a/web/default/src/features/subscriptions/components/dialogs/user-subscriptions-dialog.tsx b/web/default/src/features/subscriptions/components/dialogs/user-subscriptions-dialog.tsx index 2b2bf90e..0c3e18e4 100644 --- a/web/default/src/features/subscriptions/components/dialogs/user-subscriptions-dialog.tsx +++ b/web/default/src/features/subscriptions/components/dialogs/user-subscriptions-dialog.tsx @@ -16,13 +16,13 @@ along with this program. If not, see . 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 ( ) - if (props.sub.status === 'cancelled') + } + if (props.sub.status === 'cancelled') { return ( ) + } return ( ([]) const [subs, setSubs] = useState([]) const [selectedPlanId, setSelectedPlanId] = useState('') + 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 ( <> @@ -204,17 +244,15 @@ export function UserSubscriptionsDialog(props: Props) {