From 78b41dc33802138941234d7cf0ef78616a6d92d3 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 05:49:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20multi-tariff=20bulk=20actions=20UI=20?= =?UTF-8?q?=E2=80=94=20subscription-level=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect multi-tariff mode when users have multiple subscriptions - Expandable user rows with chevron: click to show subscription sub-rows - Each subscription sub-row shows: tariff name, status badge, days remaining (color-coded green/amber/red), traffic progress bar - Independent subscription checkboxes for subscription-level actions - FloatingActionBar shows dual counters: users (accent) + subscriptions (green), actions grouped by target type with disabled state - Subscription-level actions send subscription_ids, user-level send user_ids to the backend - Backward compatible: single-tariff mode unchanged (no chevrons, no sub-rows, user-only selection) - i18n: subscriptionsSelected, usersSelected, expand/collapse, daysUnit, trafficOf, trafficGbUnit (ru + en) --- src/api/adminBulkActions.ts | 4 +- src/api/adminUsers.ts | 12 + src/locales/en.json | 12 +- src/locales/ru.json | 12 +- src/pages/AdminBulkActions.tsx | 565 ++++++++++++++++++++++++++------- 5 files changed, 484 insertions(+), 121 deletions(-) diff --git a/src/api/adminBulkActions.ts b/src/api/adminBulkActions.ts index d0a0494..f74e8af 100644 --- a/src/api/adminBulkActions.ts +++ b/src/api/adminBulkActions.ts @@ -14,7 +14,8 @@ export type BulkActionType = export interface BulkActionRequest { action: BulkActionType; - user_ids: number[]; + user_ids?: number[]; + subscription_ids?: number[]; params: BulkActionParams; dry_run?: boolean; } @@ -48,6 +49,7 @@ export interface BulkProgressEvent { current: number; total: number; user_id: number; + subscription_id?: number; username?: string; success: boolean; message?: string; diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 5e33075..fc223ad 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -33,6 +33,17 @@ export interface UserPromoGroupInfo { is_default: boolean; } +export interface UserListItemSubscription { + id: number; + tariff_id: number | null; + tariff_name: string | null; + status: string; + end_date: string | null; + days_remaining: number; + traffic_used_gb: number; + traffic_limit_gb: number; +} + export interface UserListItem { id: number; telegram_id: number; @@ -62,6 +73,7 @@ export interface UserListItem { has_restrictions: boolean; restriction_topup: boolean; restriction_subscription: boolean; + subscriptions?: UserListItemSubscription[]; } export interface UsersListResponse { diff --git a/src/locales/en.json b/src/locales/en.json index b7db59f..4a4a6cc 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2095,7 +2095,17 @@ "daysRemaining": "Days", "promoGroup": "Group", "spent": "Spent" - } + }, + "subscriptionsSelected": "{{count}} subscriptions selected", + "usersSelected": "{{count}} users selected", + "expandSubscriptions": "Show subscriptions", + "collapseSubscriptions": "Hide subscriptions", + "noSubscriptions": "No subscriptions", + "subscriptionTarget": "Target: subscriptions", + "userTarget": "Target: users", + "daysUnit": "d", + "trafficOf": "of", + "trafficGbUnit": "GB" }, "theme": { "accentColor": "Accent color", diff --git a/src/locales/ru.json b/src/locales/ru.json index 74d0517..c971e1f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3843,7 +3843,17 @@ "daysRemaining": "Дни", "promoGroup": "Группа", "spent": "Потрачено" - } + }, + "subscriptionsSelected": "{{count}} подписок выбрано", + "usersSelected": "{{count}} пользователей выбрано", + "expandSubscriptions": "Показать подписки", + "collapseSubscriptions": "Скрыть подписки", + "noSubscriptions": "Нет подписок", + "subscriptionTarget": "Цель: подписки", + "userTarget": "Цель: пользователи", + "daysUnit": "дн.", + "trafficOf": "из", + "trafficGbUnit": "ГБ" }, "theme": { "accentColor": "Акцентный цвет", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index d3ed81d..0f2ceb1 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { @@ -8,7 +8,7 @@ import { type ColumnDef, type RowSelectionState, } from '@tanstack/react-table'; -import { adminUsersApi, type UserListItem } from '../api/adminUsers'; +import { adminUsersApi, type UserListItem, type UserListItemSubscription } from '../api/adminUsers'; import { tariffsApi, type TariffListItem } from '../api/tariffs'; import { promocodesApi, type PromoGroup } from '../api/promocodes'; import { @@ -119,6 +119,160 @@ const ChevronDownIcon = () => ( ); +const ChevronExpandIcon = ({ expanded }: { expanded: boolean }) => ( + + + +); + +// ============ Action target helpers ============ + +const SUBSCRIPTION_LEVEL_ACTIONS: Set = new Set([ + 'extend_subscription', + 'add_days', + 'cancel_subscription', + 'activate_subscription', + 'change_tariff', + 'add_traffic', +]); + +function isSubscriptionLevelAction(action: BulkActionType): boolean { + return SUBSCRIPTION_LEVEL_ACTIONS.has(action); +} + +// ============ Subscription sub-row ============ + +interface SubscriptionSubRowProps { + subscription: UserListItemSubscription; + isSelected: boolean; + onToggleSelect: () => void; + isMultiTariff: boolean; +} + +function SubscriptionSubRow({ + subscription, + isSelected, + onToggleSelect, + isMultiTariff, +}: SubscriptionSubRowProps) { + const { t } = useTranslation(); + + const days = subscription.days_remaining; + const daysColor = + days === 0 ? 'text-error-400' : days <= 7 ? 'text-amber-400' : 'text-success-400'; + + const trafficPercent = + subscription.traffic_limit_gb > 0 + ? Math.min(100, (subscription.traffic_used_gb / subscription.traffic_limit_gb) * 100) + : 0; + const trafficBarColor = + trafficPercent >= 90 ? 'bg-error-400' : trafficPercent >= 70 ? 'bg-amber-400' : 'bg-accent-400'; + + return ( + + {/* Checkbox cell */} + + {isMultiTariff && ( +
+ +
+ )} + + + {/* Subscription info — spans remaining columns */} + +
+ {/* Tariff name */} +
+ + + {subscription.tariff_name || '—'} + +
+ + {/* Status */} + + + {/* Days remaining */} + + {days} + + {t('admin.bulkActions.daysUnit')} + + + + {/* Traffic progress */} +
+ + {subscription.traffic_used_gb.toFixed(1)} {t('admin.bulkActions.trafficOf')}{' '} + {subscription.traffic_limit_gb} {t('admin.bulkActions.trafficGbUnit')} + +
+
+
+
+
+ + + ); +} + // ============ Status badge ============ function StatusBadge({ status }: { status: string | null }) { @@ -757,13 +911,19 @@ function ActionModal({ // ============ Floating Action Bar ============ -function FloatingActionBar({ - selectedCount, - onAction, -}: { - selectedCount: number; +interface FloatingActionBarProps { + selectedUserCount: number; + selectedSubscriptionCount: number; + isMultiTariff: boolean; onAction: (type: BulkActionType) => void; -}) { +} + +function FloatingActionBar({ + selectedUserCount, + selectedSubscriptionCount, + isMultiTariff, + onAction, +}: FloatingActionBarProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const menuRef = useRef(null); @@ -778,7 +938,8 @@ function FloatingActionBar({ return () => document.removeEventListener('mousedown', handler); }, []); - if (selectedCount === 0) return null; + const hasAnySelection = selectedUserCount > 0 || selectedSubscriptionCount > 0; + if (!hasAnySelection) return null; const actions: ActionConfig[] = [ { @@ -837,14 +998,31 @@ function FloatingActionBar({ ref={menuRef} className="relative flex w-full max-w-2xl items-center gap-3 rounded-2xl border border-dark-700/60 bg-dark-800/80 px-5 py-3 shadow-2xl backdrop-blur-xl" > - {/* Selection count */} + {/* Selection counts */}
-
- {selectedCount} -
- - {t('admin.bulkActions.selectedCount', { count: selectedCount })} - + {selectedUserCount > 0 && ( +
+
+ {selectedUserCount} +
+ + {t('admin.bulkActions.usersSelected', { count: selectedUserCount })} + +
+ )} + {isMultiTariff && selectedSubscriptionCount > 0 && ( +
+ {selectedUserCount > 0 &&
} +
+ {selectedSubscriptionCount} +
+ + {t('admin.bulkActions.subscriptionsSelected', { + count: selectedSubscriptionCount, + })} + +
+ )}
@@ -860,25 +1038,75 @@ function FloatingActionBar({ {open && ( -
- {actions.map((a) => ( - - ))} +
+ )} + {actions + .filter((a) => isSubscriptionLevelAction(a.type)) + .map((a) => { + const count = isMultiTariff ? selectedSubscriptionCount : selectedUserCount; + const disabled = count === 0; + return ( + + ); + })} + + {/* User-level actions */} + {isMultiTariff && ( +
+ + {t('admin.bulkActions.userTarget')} + +
+ )} + {actions + .filter((a) => !isSubscriptionLevelAction(a.type)) + .map((a) => { + const disabled = selectedUserCount === 0; + return ( + + ); + })}
)}
@@ -917,6 +1145,8 @@ export default function AdminBulkActions() { // Selection const [rowSelection, setRowSelection] = useState({}); + const [subscriptionSelection, setSubscriptionSelection] = useState>({}); + const [expandedRows, setExpandedRows] = useState>({}); // Modal const [modal, setModal] = useState({ @@ -930,6 +1160,12 @@ export default function AdminBulkActions() { // Debounce timer ref const searchTimerRef = useRef>(undefined); + // ---- Multi-tariff detection ---- + const isMultiTariff = useMemo( + () => users.some((u) => (u.subscriptions?.length ?? 0) > 1), + [users], + ); + // ---- Data loading ---- const loadUsers = useCallback(async () => { @@ -979,13 +1215,19 @@ export default function AdminBulkActions() { // ---- Handlers ---- + const clearAllSelections = useCallback(() => { + setRowSelection({}); + setSubscriptionSelection({}); + setExpandedRows({}); + }, []); + const handleSearchChange = (value: string) => { setSearchInput(value); clearTimeout(searchTimerRef.current); searchTimerRef.current = setTimeout(() => { setOffset(0); setCommittedSearch(value); - setRowSelection({}); + clearAllSelections(); }, 400); }; @@ -999,29 +1241,29 @@ export default function AdminBulkActions() { clearTimeout(searchTimerRef.current); setOffset(0); setCommittedSearch(searchInput); - setRowSelection({}); + clearAllSelections(); }; const handleStatusFilterChange = (v: string) => { setStatusFilter(v as SubscriptionStatusFilter); setOffset(0); - setRowSelection({}); + clearAllSelections(); }; const handleTariffFilterChange = (v: string) => { setTariffFilter(v); setOffset(0); - setRowSelection({}); + clearAllSelections(); }; const handlePromoGroupFilterChange = (v: string) => { setPromoGroupFilter(v); setOffset(0); - setRowSelection({}); + clearAllSelections(); }; const handleRefresh = () => { - setRowSelection({}); + clearAllSelections(); loadUsers(); }; @@ -1032,14 +1274,38 @@ export default function AdminBulkActions() { .filter((id) => id > 0); }, [rowSelection]); + const selectedSubscriptionIds = useMemo(() => { + return Object.keys(subscriptionSelection) + .filter((k) => subscriptionSelection[Number(k)]) + .map(Number); + }, [subscriptionSelection]); + + const toggleExpandRow = useCallback((userId: number) => { + setExpandedRows((prev) => ({ + ...prev, + [userId]: !prev[userId], + })); + }, []); + + const toggleSubscriptionSelection = useCallback((subscriptionId: number) => { + setSubscriptionSelection((prev) => ({ + ...prev, + [subscriptionId]: !prev[subscriptionId], + })); + }, []); + const handleOpenAction = (type: BulkActionType) => { setModal({ open: true, action: type, loading: false, result: null, progress: null }); }; const handleExecuteAction = async (params: BulkActionParams) => { - if (!modal.action || selectedUserIds.length === 0) return; + if (!modal.action) return; - const totalCount = selectedUserIds.length; + const isSubAction = isMultiTariff && isSubscriptionLevelAction(modal.action); + const targetIds = isSubAction ? selectedSubscriptionIds : selectedUserIds; + if (targetIds.length === 0) return; + + const totalCount = targetIds.length; setModal((prev) => ({ ...prev, loading: true, @@ -1052,55 +1318,52 @@ export default function AdminBulkActions() { }, })); + const requestPayload = isSubAction + ? { action: modal.action, subscription_ids: targetIds, params } + : { action: modal.action, user_ids: targetIds, params }; + try { - await adminBulkActionsApi.executeWithStream( - { - action: modal.action, - user_ids: selectedUserIds, - params, - }, - (event) => { - if (event.type === 'progress') { - setModal((prev) => ({ - ...prev, - progress: prev.progress - ? { - current: event.current, - total: event.total, - successCount: prev.progress.successCount + (event.success ? 1 : 0), - errorCount: prev.progress.errorCount + (event.success ? 0 : 1), - log: [...prev.progress.log, event], - } - : prev.progress, - })); - } else if (event.type === 'complete') { - setModal((prev) => { - // Build errors from accumulated progress log - const errors = (prev.progress?.log ?? []) - .filter((e) => !e.success) - .map((e) => ({ - user_id: e.user_id, - username: e.username, - error: e.message || e.error || '', - })); - return { - ...prev, - loading: false, - progress: null, - result: { - success: event.error_count === 0, + await adminBulkActionsApi.executeWithStream(requestPayload, (event) => { + if (event.type === 'progress') { + setModal((prev) => ({ + ...prev, + progress: prev.progress + ? { + current: event.current, total: event.total, - success_count: event.success_count, - error_count: event.error_count, - skipped_count: event.skipped_count || 0, - errors, - }, - }; - }); - loadUsers(); - } - }, - ); + successCount: prev.progress.successCount + (event.success ? 1 : 0), + errorCount: prev.progress.errorCount + (event.success ? 0 : 1), + log: [...prev.progress.log, event], + } + : prev.progress, + })); + } else if (event.type === 'complete') { + setModal((prev) => { + // Build errors from accumulated progress log + const errors = (prev.progress?.log ?? []) + .filter((e) => !e.success) + .map((e) => ({ + user_id: e.user_id, + username: e.username, + error: e.message || e.error || '', + })); + return { + ...prev, + loading: false, + progress: null, + result: { + success: event.error_count === 0, + total: event.total, + success_count: event.success_count, + error_count: event.error_count, + skipped_count: event.skipped_count || 0, + errors, + }, + }; + }); + loadUsers(); + } + }); // If stream ended without a complete event, finalize from progress setModal((prev) => { @@ -1149,7 +1412,7 @@ export default function AdminBulkActions() { const handleCloseModal = () => { if (modal.loading) return; if (modal.result) { - setRowSelection({}); + clearAllSelections(); } setModal({ open: false, action: null, loading: false, result: null, progress: null }); }; @@ -1215,13 +1478,43 @@ export default function AdminBulkActions() { size: 200, cell: ({ row }) => { const user = row.original; + const subCount = user.subscriptions?.length ?? 0; + const canExpand = subCount > 1; + const isExpanded = expandedRows[user.id] ?? false; return (
-
- {user.first_name?.[0] || user.username?.[0] || '?'} -
+ {canExpand ? ( + + ) : ( +
+ {user.first_name?.[0] || user.username?.[0] || '?'} +
+ )}
-
{user.full_name}
+
+ + {user.full_name} + + {canExpand && ( + + {subCount} + + )} +
{user.username ? `@${user.username}` : `ID: ${user.telegram_id}`}
@@ -1325,7 +1618,7 @@ export default function AdminBulkActions() { }, }, ], - [t, formatWithCurrency], + [t, formatWithCurrency, expandedRows, toggleExpandRow], ); const table = useReactTable({ @@ -1474,25 +1767,50 @@ export default function AdminBulkActions() { ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - { + const user = row.original; + const subs = user.subscriptions ?? []; + const canExpand = subs.length > 1; + const isExpanded = expandedRows[user.id] ?? false; + + return ( + + {/* User row */} + - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + + {/* Subscription sub-rows (only when expanded and multi-sub) */} + {canExpand && + isExpanded && + subs.map((sub) => ( + toggleSubscriptionSelection(sub.id)} + isMultiTariff={isMultiTariff} + /> + ))} + + {/* Single subscription — auto-select with user (no separate sub-row) */} + + ); + })}
@@ -1509,7 +1827,7 @@ export default function AdminBulkActions() {