From 312e0b492707a646fcb6f0ee71b1568389203043 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Apr 2026 04:40:53 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20bulk=20actions=20=E2=80=94=20live=20pro?= =?UTF-8?q?gress,=20grant=20subscription,=20error=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Real-time progress: SSE streaming with animated progress bar, live success/error counters, scrollable log of last 10 results with auto-scroll, percentage display - New action "Выдать подписку": tariff selector + days input, warning about users with existing subscriptions being skipped - Error details: collapsible section with per-user error list, colored summary line (green successes / red errors) - Fix: modal cannot be closed during loading (backdrop + Escape) - Fix: row checkbox aria-labels now user-specific - i18n: full progress/grant/error translations (ru + en) --- src/api/adminBulkActions.ts | 106 +++++++- src/locales/en.json | 21 +- src/locales/ru.json | 21 +- src/pages/AdminBulkActions.tsx | 446 +++++++++++++++++++++++++++++---- 4 files changed, 535 insertions(+), 59 deletions(-) diff --git a/src/api/adminBulkActions.ts b/src/api/adminBulkActions.ts index 5ec403f..4a35afe 100644 --- a/src/api/adminBulkActions.ts +++ b/src/api/adminBulkActions.ts @@ -1,4 +1,5 @@ import apiClient from './client'; +import { tokenStorage } from '../utils/token'; export type BulkActionType = | 'extend' @@ -7,7 +8,8 @@ export type BulkActionType = | 'change_tariff' | 'add_traffic' | 'add_balance' - | 'assign_promo_group'; + | 'assign_promo_group' + | 'grant_subscription'; export interface BulkActionRequest { action: BulkActionType; @@ -23,20 +25,114 @@ export interface BulkActionParams { promo_group_id?: number | null; } +export interface BulkActionErrorItem { + user_id: number; + username?: string; + error: string; +} + export interface BulkActionResult { success: boolean; total: number; success_count: number; error_count: number; - errors: Array<{ - user_id: number; - error: string; - }>; + errors: BulkActionErrorItem[]; } +export interface BulkProgressEvent { + type: 'progress'; + current: number; + total: number; + user_id: number; + username?: string; + success: boolean; + message?: string; + error?: string; +} + +export interface BulkCompleteEvent { + type: 'complete'; + success: boolean; + total: number; + success_count: number; + error_count: number; + errors: BulkActionErrorItem[]; +} + +export type BulkSSEEvent = BulkProgressEvent | BulkCompleteEvent; + +const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; + export const adminBulkActionsApi = { execute: async (data: BulkActionRequest): Promise => { const response = await apiClient.post('/cabinet/admin/bulk/execute', data); return response.data; }, + + executeWithStream: async ( + data: BulkActionRequest, + onEvent: (event: BulkSSEEvent) => void, + signal?: AbortSignal, + ): Promise => { + const token = tokenStorage.getAccessToken(); + const response = await fetch(`${API_BASE_URL}/cabinet/admin/bulk/execute?stream=true`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(data), + signal, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const contentType = response.headers.get('content-type') || ''; + + if (contentType.includes('text/event-stream') && response.body) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('data: ')) { + try { + const event = JSON.parse(trimmed.slice(6)) as BulkSSEEvent; + onEvent(event); + } catch { + // skip malformed SSE lines + } + } + } + } + + // process remaining buffer + if (buffer.trim().startsWith('data: ')) { + try { + const event = JSON.parse(buffer.trim().slice(6)) as BulkSSEEvent; + onEvent(event); + } catch { + // skip + } + } + } else { + // Fallback: non-streaming JSON response + const result = (await response.json()) as BulkActionResult; + onEvent({ + type: 'complete', + ...result, + }); + } + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index 190eb65..b7db59f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2028,6 +2028,8 @@ "selectedCount": "Selected: {{count}}", "selectAll": "Select all", "deselectAll": "Deselect all", + "selectUser": "Select user {{name}}", + "deselectUser": "Deselect user {{name}}", "noResults": "No users found", "actions": { "extend": "Extend subscription", @@ -2036,7 +2038,8 @@ "changeTariff": "Change tariff", "addTraffic": "Add traffic", "addBalance": "Add balance", - "assignPromoGroup": "Assign promo group" + "assignPromoGroup": "Assign promo group", + "grantSubscription": "Grant subscription" }, "params": { "days": "Number of days", @@ -2046,12 +2049,28 @@ "promoGroup": "Promo group", "removePromoGroup": "Remove promo group" }, + "grantSubscription": { + "warning": "Users who already have a subscription on the selected tariff will be skipped" + }, "confirm": "Apply", "cancel": "Cancel", "executing": "Executing...", "complete": "Complete", "successCount": "Succeeded: {{count}}", "errorCount": "Errors: {{count}}", + "progress": { + "processed": "Processed: {{current}} / {{total}}", + "succeeded": "succeeded", + "failed": "failed", + "ok": "OK", + "errorGeneric": "Error", + "doNotClose": "Please do not close this window while the operation is in progress", + "summarySuccess": "{{count}} succeeded", + "summaryErrors": "{{count}} errors" + }, + "errors": { + "title": "{{count}} errors — show details" + }, "filters": { "search": "Search by ID, username, email", "status": "Subscription status", diff --git a/src/locales/ru.json b/src/locales/ru.json index b4499fc..74d0517 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3776,6 +3776,8 @@ "selectedCount": "Выбрано: {{count}}", "selectAll": "Выбрать всех", "deselectAll": "Снять выделение", + "selectUser": "Выбрать пользователя {{name}}", + "deselectUser": "Снять выбор пользователя {{name}}", "noResults": "Пользователи не найдены", "actions": { "extend": "Продлить подписку", @@ -3784,7 +3786,8 @@ "changeTariff": "Сменить тариф", "addTraffic": "Начислить трафик", "addBalance": "Начислить баланс", - "assignPromoGroup": "Назначить промогруппу" + "assignPromoGroup": "Назначить промогруппу", + "grantSubscription": "Выдать подписку" }, "params": { "days": "Количество дней", @@ -3794,12 +3797,28 @@ "promoGroup": "Промогруппа", "removePromoGroup": "Убрать промогруппу" }, + "grantSubscription": { + "warning": "Пользователи, у которых уже есть подписка на выбранный тариф, будут пропущены" + }, "confirm": "Применить", "cancel": "Отмена", "executing": "Выполнение...", "complete": "Выполнено", "successCount": "Успешно: {{count}}", "errorCount": "Ошибок: {{count}}", + "progress": { + "processed": "Обработано: {{current}} / {{total}}", + "succeeded": "успешно", + "failed": "ошибок", + "ok": "ОК", + "errorGeneric": "Ошибка", + "doNotClose": "Не закрывайте это окно, пока операция выполняется", + "summarySuccess": "{{count}} успешно", + "summaryErrors": "{{count}} ошибок" + }, + "errors": { + "title": "{{count}} ошибок — показать детали" + }, "filters": { "search": "Поиск по ID, нику, email", "status": "Статус подписки", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index b561261..b039a82 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -16,6 +16,7 @@ import { type BulkActionType, type BulkActionParams, type BulkActionResult, + type BulkProgressEvent, } from '../api/adminBulkActions'; import { usePlatform } from '../platform/hooks/usePlatform'; import { useCurrency } from '../hooks/useCurrency'; @@ -32,11 +33,20 @@ interface ActionConfig { colorClass: string; } +interface ProgressState { + current: number; + total: number; + successCount: number; + errorCount: number; + log: BulkProgressEvent[]; +} + interface ModalState { open: boolean; action: BulkActionType | null; loading: boolean; result: BulkActionResult | null; + progress: ProgressState | null; } // ============ Icons ============ @@ -235,6 +245,165 @@ function DropdownSelect({ ); } +// ============ Progress View ============ + +function ProgressView({ progress }: { progress: ProgressState }) { + const { t } = useTranslation(); + const logEndRef = useRef(null); + const percentage = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; + + useEffect(() => { + logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [progress.log.length]); + + return ( +
+ {/* Progress bar */} +
+
+ + {t('admin.bulkActions.progress.processed', { + current: progress.current, + total: progress.total, + })} + + {percentage}% +
+
+
+
+
+ + {/* Counters */} +
+
+
+ {progress.successCount} + {t('admin.bulkActions.progress.succeeded')} +
+
+
+ {progress.errorCount} + {t('admin.bulkActions.progress.failed')} +
+
+ + {/* Live log */} + {progress.log.length > 0 && ( +
+
+ {progress.log.slice(-10).map((entry, idx) => ( +
+ {entry.success ? ( + + ) : ( + + )} + + {entry.username ? `@${entry.username}` : `#${entry.user_id}`} + + + {entry.success + ? entry.message || t('admin.bulkActions.progress.ok') + : entry.error || t('admin.bulkActions.progress.errorGeneric')} + +
+ ))} +
+
+
+ )} +
+ ); +} + +// ============ Error Details ============ + +function ErrorDetails({ result }: { result: BulkActionResult }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + if (result.error_count === 0 || result.errors.length === 0) return null; + + return ( +
+ + {expanded && ( +
+
+ {result.errors.map((err, idx) => ( +
+ + + {err.username ? `@${err.username}` : `#${err.user_id}`} + + {err.error} +
+ ))} +
+
+ )} +
+ ); +} + // ============ Action Modal ============ interface ActionModalProps { @@ -260,12 +429,17 @@ function ActionModal({ const [trafficGb, setTrafficGb] = useState(10); const [balanceRub, setBalanceRub] = useState(100); const [promoGroupId, setPromoGroupId] = useState(promoGroups[0]?.id ?? null); + const [grantTariffId, setGrantTariffId] = useState(tariffs[0]?.id ?? 0); + const [grantDays, setGrantDays] = useState(30); useEffect(() => { if (tariffs.length > 0 && tariffId === 0) { setTariffId(tariffs[0].id); } - }, [tariffs, tariffId]); + if (tariffs.length > 0 && grantTariffId === 0) { + setGrantTariffId(tariffs[0].id); + } + }, [tariffs, tariffId, grantTariffId]); useEffect(() => { if (promoGroups.length > 0 && promoGroupId === null) { @@ -273,6 +447,18 @@ function ActionModal({ } }, [promoGroups, promoGroupId]); + // Escape key handler — only when not loading + useEffect(() => { + if (!modal.open) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !modal.loading) { + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [modal.open, modal.loading, onClose]); + if (!modal.open || !modal.action) return null; const actionLabelKeys: Record = { @@ -283,6 +469,7 @@ function ActionModal({ add_traffic: 'admin.bulkActions.actions.addTraffic', add_balance: 'admin.bulkActions.actions.addBalance', assign_promo_group: 'admin.bulkActions.actions.assignPromoGroup', + grant_subscription: 'admin.bulkActions.actions.grantSubscription', }; const handleSubmit = () => { @@ -303,10 +490,20 @@ function ActionModal({ case 'assign_promo_group': params.promo_group_id = promoGroupId; break; + case 'grant_subscription': + params.tariff_id = grantTariffId; + params.days = grantDays; + break; } onExecute(params); }; + const handleBackdropClick = () => { + if (!modal.loading) { + onClose(); + } + }; + const renderInputs = () => { switch (modal.action) { case 'extend': @@ -386,6 +583,40 @@ function ActionModal({ />
); + case 'grant_subscription': + return ( +
+
+ + ({ value: String(tt.id), label: tt.name }))} + onChange={(v) => setGrantTariffId(Number(v))} + /> +
+
+ + setGrantDays(Number(e.target.value))} + className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" + /> +
+ {/* Warning about users with existing subscriptions */} +
+

+ {t('admin.bulkActions.grantSubscription.warning')} +

+
+
+ ); default: return null; } @@ -397,30 +628,64 @@ function ActionModal({ role="dialog" aria-modal="true" > - {/* Backdrop */} -
+ {/* Backdrop — no close on click during loading */} +
{/* Modal */}
{/* Header */}

{t(actionLabelKeys[modal.action])}

- + {!modal.loading && ( + + )}
- {/* Result view */} - {modal.result ? ( + {/* Progress view during execution */} + {modal.loading && modal.progress ? (
+ +

+ {t('admin.bulkActions.progress.doNotClose')} +

+
+ ) : modal.result ? ( + /* Result view */ +
+ {/* Summary line */}
{t('admin.bulkActions.complete')}
+ {/* Colored summary */} +
+ + {t('admin.bulkActions.progress.summarySuccess', { + count: modal.result.success_count, + })} + + {modal.result.error_count > 0 && ( + <> + + + {t('admin.bulkActions.progress.summaryErrors', { + count: modal.result.error_count, + })} + + + )} +
@@ -442,6 +707,10 @@ function ActionModal({ )}
+ + {/* Collapsible error details */} + +
@@ -554,6 +816,12 @@ function FloatingActionBar({ icon: , colorClass: 'text-warning-400 hover:bg-warning-500/10', }, + { + type: 'grant_subscription', + labelKey: 'admin.bulkActions.actions.grantSubscription', + icon: , + colorClass: 'text-success-400 hover:bg-success-500/10', + }, { type: 'assign_promo_group', labelKey: 'admin.bulkActions.actions.assignPromoGroup', @@ -655,6 +923,7 @@ export default function AdminBulkActions() { action: null, loading: false, result: null, + progress: null, }); // Debounce timer ref @@ -763,32 +1032,100 @@ export default function AdminBulkActions() { }, [rowSelection]); const handleOpenAction = (type: BulkActionType) => { - setModal({ open: true, action: type, loading: false, result: null }); + setModal({ open: true, action: type, loading: false, result: null, progress: null }); }; const handleExecuteAction = async (params: BulkActionParams) => { if (!modal.action || selectedUserIds.length === 0) return; - setModal((prev) => ({ ...prev, loading: true })); + const totalCount = selectedUserIds.length; + setModal((prev) => ({ + ...prev, + loading: true, + progress: { + current: 0, + total: totalCount, + successCount: 0, + errorCount: 0, + log: [], + }, + })); try { - const result = await adminBulkActionsApi.execute({ - action: modal.action, - user_ids: selectedUserIds, - params, + 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) => ({ + ...prev, + loading: false, + progress: null, + result: { + success: event.success, + total: event.total, + success_count: event.success_count, + error_count: event.error_count, + errors: event.errors, + }, + })); + loadUsers(); + } + }, + ); + + // If stream ended without a complete event, finalize from progress + setModal((prev) => { + if (prev.loading && prev.progress) { + return { + ...prev, + loading: false, + result: { + success: prev.progress.errorCount === 0, + total: prev.progress.total, + success_count: prev.progress.successCount, + error_count: prev.progress.errorCount, + errors: prev.progress.log + .filter((e) => !e.success) + .map((e) => ({ + user_id: e.user_id, + username: e.username, + error: e.error || '', + })), + }, + progress: null, + }; + } + return prev; }); - setModal((prev) => ({ ...prev, loading: false, result })); - // Refresh the user list after action + loadUsers(); } catch { setModal((prev) => ({ ...prev, loading: false, + progress: null, result: { success: false, - total: selectedUserIds.length, - success_count: 0, - error_count: selectedUserIds.length, + total: totalCount, + success_count: prev.progress?.successCount ?? 0, + error_count: totalCount - (prev.progress?.successCount ?? 0), errors: [], }, })); @@ -796,10 +1133,11 @@ export default function AdminBulkActions() { }; const handleCloseModal = () => { + if (modal.loading) return; if (modal.result) { setRowSelection({}); } - setModal({ open: false, action: null, loading: false, result: null }); + setModal({ open: false, action: null, loading: false, result: null, progress: null }); }; // ---- TanStack Table ---- @@ -830,26 +1168,30 @@ export default function AdminBulkActions() {
), - cell: ({ row }) => ( -
- -
- ), + cell: ({ row }) => { + const userName = + row.original.full_name || row.original.username || String(row.original.telegram_id); + return ( +
+ +
+ ); + }, enableSorting: false, }, {