feat: bulk actions — live progress, grant subscription, error details

- 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)
This commit is contained in:
Fringg
2026-04-24 04:40:53 +03:00
parent ebe9d9be48
commit 312e0b4927
4 changed files with 535 additions and 59 deletions

View File

@@ -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<BulkActionResult> => {
const response = await apiClient.post('/cabinet/admin/bulk/execute', data);
return response.data;
},
executeWithStream: async (
data: BulkActionRequest,
onEvent: (event: BulkSSEEvent) => void,
signal?: AbortSignal,
): Promise<void> => {
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,
});
}
},
};