diff --git a/src/App.tsx b/src/App.tsx index b030a73..0c8423c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -123,6 +123,7 @@ const AdminRemnawave = lazyWithRetry(() => import('./pages/AdminRemnawave')); const AdminRemnawaveSquadDetail = lazyWithRetry(() => import('./pages/AdminRemnawaveSquadDetail')); const AdminEmailTemplates = lazyWithRetry(() => import('./pages/AdminEmailTemplates')); const AdminTrafficUsage = lazyWithRetry(() => import('./pages/AdminTrafficUsage')); +const AdminBulkActions = lazyWithRetry(() => import('./pages/AdminBulkActions')); const AdminSalesStats = lazyWithRetry(() => import('./pages/AdminSalesStats')); const AdminUpdates = lazyWithRetry(() => import('./pages/AdminUpdates')); const AdminUserDetail = lazyWithRetry(() => import('./pages/AdminUserDetail')); @@ -966,6 +967,16 @@ function App() { } /> + + + + + + } + /> ; +} + +export const adminBulkActionsApi = { + execute: async (data: BulkActionRequest): Promise => { + const response = await apiClient.post('/cabinet/admin/bulk/execute', data); + return response.data; + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index 9fb58e4..190eb65 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1125,7 +1125,8 @@ "salesStats": "Sales Statistics", "landings": "Landings", "referralNetwork": "Referral Network", - "news": "News" + "news": "News", + "bulkActions": "Bulk Actions" }, "panel": { "title": "Admin Panel", @@ -2021,6 +2022,62 @@ "telegramOidcClientSecret": "Client Secret" } }, + "bulkActions": { + "title": "Bulk Actions", + "subtitle": "User management", + "selectedCount": "Selected: {{count}}", + "selectAll": "Select all", + "deselectAll": "Deselect all", + "noResults": "No users found", + "actions": { + "extend": "Extend subscription", + "cancel": "Deactivate subscriptions", + "activate": "Activate subscriptions", + "changeTariff": "Change tariff", + "addTraffic": "Add traffic", + "addBalance": "Add balance", + "assignPromoGroup": "Assign promo group" + }, + "params": { + "days": "Number of days", + "tariff": "Select tariff", + "trafficGb": "Traffic amount (GB)", + "balanceRub": "Amount (RUB)", + "promoGroup": "Promo group", + "removePromoGroup": "Remove promo group" + }, + "confirm": "Apply", + "cancel": "Cancel", + "executing": "Executing...", + "complete": "Complete", + "successCount": "Succeeded: {{count}}", + "errorCount": "Errors: {{count}}", + "filters": { + "search": "Search by ID, username, email", + "status": "Subscription status", + "tariff": "Tariff", + "promoGroup": "Promo group", + "allStatuses": "All statuses", + "allTariffs": "All tariffs", + "allGroups": "All groups" + }, + "statuses": { + "active": "Active", + "expired": "Expired", + "trial": "Trial", + "limited": "Limited", + "disabled": "Disabled" + }, + "columns": { + "user": "User", + "status": "Status", + "tariff": "Tariff", + "balance": "Balance", + "daysRemaining": "Days", + "promoGroup": "Group", + "spent": "Spent" + } + }, "theme": { "accentColor": "Accent color", "customizeColors": "Customize colors", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2c69a25..b4499fc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1146,7 +1146,8 @@ "salesStats": "Статистика продаж", "landings": "Лендинги", "referralNetwork": "Реферальная сеть", - "news": "Новости" + "news": "Новости", + "bulkActions": "Массовые действия" }, "panel": { "title": "Панель администратора", @@ -3769,6 +3770,62 @@ "users": "Пользователи", "security": "Безопасность" }, + "bulkActions": { + "title": "Массовые действия", + "subtitle": "Управление пользователями", + "selectedCount": "Выбрано: {{count}}", + "selectAll": "Выбрать всех", + "deselectAll": "Снять выделение", + "noResults": "Пользователи не найдены", + "actions": { + "extend": "Продлить подписку", + "cancel": "Деактивировать подписки", + "activate": "Активировать подписки", + "changeTariff": "Сменить тариф", + "addTraffic": "Начислить трафик", + "addBalance": "Начислить баланс", + "assignPromoGroup": "Назначить промогруппу" + }, + "params": { + "days": "Количество дней", + "tariff": "Выберите тариф", + "trafficGb": "Объём трафика (ГБ)", + "balanceRub": "Сумма (руб.)", + "promoGroup": "Промогруппа", + "removePromoGroup": "Убрать промогруппу" + }, + "confirm": "Применить", + "cancel": "Отмена", + "executing": "Выполнение...", + "complete": "Выполнено", + "successCount": "Успешно: {{count}}", + "errorCount": "Ошибок: {{count}}", + "filters": { + "search": "Поиск по ID, нику, email", + "status": "Статус подписки", + "tariff": "Тариф", + "promoGroup": "Промогруппа", + "allStatuses": "Все статусы", + "allTariffs": "Все тарифы", + "allGroups": "Все группы" + }, + "statuses": { + "active": "Активна", + "expired": "Истекла", + "trial": "Триал", + "limited": "Ограничена", + "disabled": "Отключена" + }, + "columns": { + "user": "Пользователь", + "status": "Статус", + "tariff": "Тариф", + "balance": "Баланс", + "daysRemaining": "Дни", + "promoGroup": "Группа", + "spent": "Потрачено" + } + }, "theme": { "accentColor": "Акцентный цвет", "customizeColors": "Настроить цвета", diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx new file mode 100644 index 0000000..8a7bfa0 --- /dev/null +++ b/src/pages/AdminBulkActions.tsx @@ -0,0 +1,1201 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { + useReactTable, + getCoreRowModel, + flexRender, + type ColumnDef, + type RowSelectionState, +} from '@tanstack/react-table'; +import { adminUsersApi, type UserListItem } from '../api/adminUsers'; +import { tariffsApi, type TariffListItem } from '../api/tariffs'; +import { promocodesApi, type PromoGroup } from '../api/promocodes'; +import { + adminBulkActionsApi, + type BulkActionType, + type BulkActionParams, + type BulkActionResult, +} from '../api/adminBulkActions'; +import { usePlatform } from '../platform/hooks/usePlatform'; +import { useCurrency } from '../hooks/useCurrency'; +import { cn } from '@/lib/utils'; + +// ============ Types ============ + +type SubscriptionStatusFilter = '' | 'active' | 'expired' | 'trial' | 'limited' | 'disabled'; + +interface ActionConfig { + type: BulkActionType; + labelKey: string; + icon: React.ReactNode; + colorClass: string; +} + +interface ModalState { + open: boolean; + action: BulkActionType | null; + loading: boolean; + result: BulkActionResult | null; +} + +// ============ Icons ============ + +const BackIcon = () => ( + + + +); + +const SearchIcon = () => ( + + + +); + +const ChevronLeftIcon = () => ( + + + +); + +const ChevronRightIcon = () => ( + + + +); + +const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( + + + +); + +const CheckIcon = () => ( + + + +); + +const XCloseIcon = () => ( + + + +); + +const ChevronDownIcon = () => ( + + + +); + +// ============ Status badge ============ + +function StatusBadge({ status }: { status: string | null }) { + const { t } = useTranslation(); + + const config: Record = { + active: { + class: 'border-success-500/30 bg-success-500/15 text-success-400', + labelKey: 'admin.bulkActions.statuses.active', + }, + expired: { + class: 'border-error-500/30 bg-error-500/15 text-error-400', + labelKey: 'admin.bulkActions.statuses.expired', + }, + trial: { + class: 'border-amber-500/30 bg-amber-500/15 text-amber-400', + labelKey: 'admin.bulkActions.statuses.trial', + }, + limited: { + class: 'border-amber-500/30 bg-amber-500/15 text-amber-400', + labelKey: 'admin.bulkActions.statuses.limited', + }, + disabled: { + class: 'border-dark-500/30 bg-dark-500/15 text-dark-400', + labelKey: 'admin.bulkActions.statuses.disabled', + }, + }; + + const c = config[status || ''] || config.disabled; + + return ( + + {t(c.labelKey, status || '')} + + ); +} + +// ============ Progress Bar ============ + +function ProgressBar({ loading }: { loading: boolean }) { + const [progress, setProgress] = useState(0); + const [visible, setVisible] = useState(false); + const intervalRef = useRef>(undefined); + + useEffect(() => { + if (loading) { + setProgress(0); + setVisible(true); + intervalRef.current = setInterval(() => { + setProgress((prev) => { + if (prev < 30) return prev + 8; + if (prev < 60) return prev + 3; + if (prev < 85) return prev + 1; + if (prev < 95) return prev + 0.3; + return prev; + }); + }, 100); + } else { + if (visible) { + setProgress(100); + clearInterval(intervalRef.current); + const timer = setTimeout(() => { + setVisible(false); + setProgress(0); + }, 300); + return () => clearTimeout(timer); + } + } + return () => clearInterval(intervalRef.current); + }, [loading, visible]); + + if (!visible) return null; + + return ( +
+
+
+ ); +} + +// ============ Dropdown Select ============ + +interface DropdownOption { + value: string; + label: string; +} + +function DropdownSelect({ + value, + options, + onChange, + className, +}: { + value: string; + options: DropdownOption[]; + onChange: (v: string) => void; + className?: string; +}) { + return ( +
+ +
+ +
+
+ ); +} + +// ============ Action Modal ============ + +interface ActionModalProps { + modal: ModalState; + selectedCount: number; + tariffs: TariffListItem[]; + promoGroups: PromoGroup[]; + onClose: () => void; + onExecute: (params: BulkActionParams) => void; +} + +function ActionModal({ + modal, + selectedCount, + tariffs, + promoGroups, + onClose, + onExecute, +}: ActionModalProps) { + const { t } = useTranslation(); + const [days, setDays] = useState(30); + const [tariffId, setTariffId] = useState(tariffs[0]?.id ?? 0); + const [trafficGb, setTrafficGb] = useState(10); + const [balanceRub, setBalanceRub] = useState(100); + const [promoGroupId, setPromoGroupId] = useState(promoGroups[0]?.id ?? null); + + useEffect(() => { + if (tariffs.length > 0 && tariffId === 0) { + setTariffId(tariffs[0].id); + } + }, [tariffs, tariffId]); + + useEffect(() => { + if (promoGroups.length > 0 && promoGroupId === null) { + setPromoGroupId(promoGroups[0].id); + } + }, [promoGroups, promoGroupId]); + + if (!modal.open || !modal.action) return null; + + const actionLabelKeys: Record = { + extend: 'admin.bulkActions.actions.extend', + cancel: 'admin.bulkActions.actions.cancel', + activate: 'admin.bulkActions.actions.activate', + change_tariff: 'admin.bulkActions.actions.changeTariff', + add_traffic: 'admin.bulkActions.actions.addTraffic', + add_balance: 'admin.bulkActions.actions.addBalance', + assign_promo_group: 'admin.bulkActions.actions.assignPromoGroup', + }; + + const handleSubmit = () => { + const params: BulkActionParams = {}; + switch (modal.action) { + case 'extend': + params.days = days; + break; + case 'change_tariff': + params.tariff_id = tariffId; + break; + case 'add_traffic': + params.traffic_gb = trafficGb; + break; + case 'add_balance': + params.balance_kopeks = Math.round(balanceRub * 100); + break; + case 'assign_promo_group': + params.promo_group_id = promoGroupId; + break; + } + onExecute(params); + }; + + const renderInputs = () => { + switch (modal.action) { + case 'extend': + return ( +
+ + setDays(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" + /> +
+ ); + case 'change_tariff': + return ( +
+ + ({ value: String(tt.id), label: tt.name }))} + onChange={(v) => setTariffId(Number(v))} + /> +
+ ); + case 'add_traffic': + return ( +
+ + setTrafficGb(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" + /> +
+ ); + case 'add_balance': + return ( +
+ + setBalanceRub(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" + /> +
+ ); + case 'assign_promo_group': + return ( +
+ + ({ value: String(pg.id), label: pg.name })), + ]} + onChange={(v) => setPromoGroupId(v === 'null' ? null : Number(v))} + /> +
+ ); + default: + return null; + } + }; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

{t(actionLabelKeys[modal.action])}

+ +
+ + {/* Result view */} + {modal.result ? ( +
+
+
+ {t('admin.bulkActions.complete')} +
+
+
+
+ {modal.result.success_count} +
+
+ {t('admin.bulkActions.successCount', { count: modal.result.success_count })} +
+
+ {modal.result.error_count > 0 && ( +
+
+ {modal.result.error_count} +
+
+ {t('admin.bulkActions.errorCount', { count: modal.result.error_count })} +
+
+ )} +
+
+ +
+ ) : ( + <> + {/* Affected count */} +
+

+ {t('admin.bulkActions.selectedCount', { count: selectedCount })} +

+
+ + {/* Action-specific inputs */} +
{renderInputs()}
+ + {/* Buttons */} +
+ + +
+ + )} +
+
+ ); +} + +// ============ Floating Action Bar ============ + +function FloatingActionBar({ + selectedCount, + onAction, +}: { + selectedCount: number; + onAction: (type: BulkActionType) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + if (selectedCount === 0) return null; + + const actions: ActionConfig[] = [ + { + type: 'extend', + labelKey: 'admin.bulkActions.actions.extend', + icon: , + colorClass: 'text-success-400 hover:bg-success-500/10', + }, + { + type: 'activate', + labelKey: 'admin.bulkActions.actions.activate', + icon: , + colorClass: 'text-success-400 hover:bg-success-500/10', + }, + { + type: 'cancel', + labelKey: 'admin.bulkActions.actions.cancel', + icon: , + colorClass: 'text-error-400 hover:bg-error-500/10', + }, + { + type: 'change_tariff', + labelKey: 'admin.bulkActions.actions.changeTariff', + icon: , + colorClass: 'text-accent-400 hover:bg-accent-500/10', + }, + { + type: 'add_traffic', + labelKey: 'admin.bulkActions.actions.addTraffic', + icon: , + colorClass: 'text-accent-400 hover:bg-accent-500/10', + }, + { + type: 'add_balance', + labelKey: 'admin.bulkActions.actions.addBalance', + icon: , + colorClass: 'text-warning-400 hover:bg-warning-500/10', + }, + { + type: 'assign_promo_group', + labelKey: 'admin.bulkActions.actions.assignPromoGroup', + icon: , + colorClass: 'text-accent-300 hover:bg-accent-300/10', + }, + ]; + + return ( +
+
+ {/* Selection count */} +
+
+ {selectedCount} +
+ + {t('admin.bulkActions.selectedCount', { count: selectedCount })} + +
+ +
+ + {/* Actions dropdown */} +
+ + + {open && ( +
+ {actions.map((a) => ( + + ))} +
+ )} +
+
+
+ ); +} + +// ============ Helpers ============ + +// ============ Main Page ============ + +export default function AdminBulkActions() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { capabilities } = usePlatform(); + const { formatWithCurrency } = useCurrency(); + + // Data + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [tariffs, setTariffs] = useState([]); + const [promoGroups, setPromoGroups] = useState([]); + const [loading, setLoading] = useState(true); + + // Filters + const [searchInput, setSearchInput] = useState(''); + const [committedSearch, setCommittedSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [tariffFilter, setTariffFilter] = useState(''); + const [promoGroupFilter, setPromoGroupFilter] = useState(''); + + // Pagination + const [offset, setOffset] = useState(0); + const limit = 50; + + // Selection + const [rowSelection, setRowSelection] = useState({}); + + // Modal + const [modal, setModal] = useState({ + open: false, + action: null, + loading: false, + result: null, + }); + + // Debounce timer ref + const searchTimerRef = useRef>(undefined); + + // ---- Data loading ---- + + const loadUsers = useCallback(async () => { + try { + setLoading(true); + const params: Record = { + offset, + limit, + }; + if (committedSearch) params.search = committedSearch; + if (statusFilter) params.subscription_status = statusFilter; + if (tariffFilter) params.tariff_id = Number(tariffFilter); + if (promoGroupFilter) params.promo_group_id = Number(promoGroupFilter); + + const data = await adminUsersApi.getUsers( + params as Parameters[0], + ); + setUsers(data.users); + setTotal(data.total); + } catch { + // keep stale data + } finally { + setLoading(false); + } + }, [offset, committedSearch, statusFilter, tariffFilter, promoGroupFilter]); + + useEffect(() => { + loadUsers(); + }, [loadUsers]); + + // Load tariffs and promo groups once + useEffect(() => { + const load = async () => { + try { + const [tariffData, pgData] = await Promise.all([ + tariffsApi.getTariffs(true), + promocodesApi.getPromoGroups({ limit: 200 }), + ]); + setTariffs(tariffData.tariffs); + setPromoGroups(pgData.items); + } catch { + // silently fail + } + }; + load(); + }, []); + + // ---- Handlers ---- + + const handleSearchChange = (value: string) => { + setSearchInput(value); + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setOffset(0); + setCommittedSearch(value); + setRowSelection({}); + }, 400); + }; + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + clearTimeout(searchTimerRef.current); + setOffset(0); + setCommittedSearch(searchInput); + setRowSelection({}); + }; + + const handleStatusFilterChange = (v: string) => { + setStatusFilter(v as SubscriptionStatusFilter); + setOffset(0); + setRowSelection({}); + }; + + const handleTariffFilterChange = (v: string) => { + setTariffFilter(v); + setOffset(0); + setRowSelection({}); + }; + + const handlePromoGroupFilterChange = (v: string) => { + setPromoGroupFilter(v); + setOffset(0); + setRowSelection({}); + }; + + const handleRefresh = () => { + setRowSelection({}); + loadUsers(); + }; + + const selectedUserIds = useMemo(() => { + return Object.keys(rowSelection) + .filter((k) => rowSelection[k]) + .map((idx) => users[Number(idx)]?.id) + .filter(Boolean); + }, [rowSelection, users]); + + const handleOpenAction = (type: BulkActionType) => { + setModal({ open: true, action: type, loading: false, result: null }); + }; + + const handleExecuteAction = async (params: BulkActionParams) => { + if (!modal.action || selectedUserIds.length === 0) return; + + setModal((prev) => ({ ...prev, loading: true })); + + try { + const result = await adminBulkActionsApi.execute({ + action: modal.action, + user_ids: selectedUserIds, + params, + }); + setModal((prev) => ({ ...prev, loading: false, result })); + // Refresh the user list after action + loadUsers(); + } catch { + setModal((prev) => ({ + ...prev, + loading: false, + result: { + success: false, + total: selectedUserIds.length, + success_count: 0, + error_count: selectedUserIds.length, + errors: [], + }, + })); + } + }; + + const handleCloseModal = () => { + if (modal.result) { + setRowSelection({}); + } + setModal({ open: false, action: null, loading: false, result: null }); + }; + + // ---- TanStack Table ---- + + const columns = useMemo[]>( + () => [ + { + id: 'select', + size: 40, + header: ({ table }) => ( +
+ +
+ ), + cell: ({ row }) => ( +
+ +
+ ), + enableSorting: false, + }, + { + id: 'user', + accessorFn: (row) => row.full_name, + header: t('admin.bulkActions.columns.user'), + size: 200, + cell: ({ row }) => { + const user = row.original; + return ( +
+
+ {user.first_name?.[0] || user.username?.[0] || '?'} +
+
+
{user.full_name}
+
+ {user.username ? `@${user.username}` : `ID: ${user.telegram_id}`} +
+
+
+ ); + }, + }, + { + id: 'subscription_status', + accessorFn: (row) => row.subscription_status, + header: t('admin.bulkActions.columns.status'), + size: 100, + cell: ({ row }) => { + const user = row.original; + if (!user.has_subscription) { + return -; + } + return ; + }, + }, + { + id: 'tariff', + accessorFn: (row) => row.promo_group_name, + header: t('admin.bulkActions.columns.tariff'), + size: 120, + cell: ({ row }) => { + // UserListItem doesn't have tariff_name directly; we'll show subscription info + const user = row.original; + if (!user.has_subscription) { + return -; + } + return ( + + {user.subscription_status === 'trial' + ? t('admin.bulkActions.statuses.trial') + : user.subscription_status || '-'} + + ); + }, + }, + { + id: 'balance', + accessorKey: 'balance_rubles', + header: t('admin.bulkActions.columns.balance'), + size: 100, + cell: ({ getValue }) => ( + + {formatWithCurrency(getValue() as number)} + + ), + }, + { + id: 'days_remaining', + header: t('admin.bulkActions.columns.daysRemaining'), + size: 80, + cell: ({ row }) => { + const user = row.original; + if (!user.subscription_end_date) { + return -; + } + const end = new Date(user.subscription_end_date); + const now = new Date(); + const days = Math.max( + 0, + Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)), + ); + return ( + + {days} + + ); + }, + }, + { + id: 'promo_group', + accessorFn: (row) => row.promo_group_name, + header: t('admin.bulkActions.columns.promoGroup'), + size: 120, + cell: ({ getValue }) => { + const name = getValue() as string | null; + return name ? ( + + {name} + + ) : ( + - + ); + }, + }, + { + id: 'total_spent', + accessorKey: 'total_spent_kopeks', + header: t('admin.bulkActions.columns.spent'), + size: 100, + cell: ({ getValue }) => { + const kopeks = getValue() as number; + return ( + + {kopeks > 0 ? formatWithCurrency(kopeks / 100) : '-'} + + ); + }, + }, + ], + [t, formatWithCurrency], + ); + + const table = useReactTable({ + data: users, + columns, + state: { rowSelection }, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + enableRowSelection: true, + getRowId: (row) => String(row.id), + }); + + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + + // ---- Status filter options ---- + const statusOptions: DropdownOption[] = [ + { value: '', label: t('admin.bulkActions.filters.allStatuses') }, + { value: 'active', label: t('admin.bulkActions.statuses.active') }, + { value: 'expired', label: t('admin.bulkActions.statuses.expired') }, + { value: 'trial', label: t('admin.bulkActions.statuses.trial') }, + { value: 'limited', label: t('admin.bulkActions.statuses.limited') }, + { value: 'disabled', label: t('admin.bulkActions.statuses.disabled') }, + ]; + + const tariffOptions: DropdownOption[] = [ + { value: '', label: t('admin.bulkActions.filters.allTariffs') }, + ...tariffs.map((tt) => ({ value: String(tt.id), label: tt.name })), + ]; + + const promoGroupOptions: DropdownOption[] = [ + { value: '', label: t('admin.bulkActions.filters.allGroups') }, + ...promoGroups.map((pg) => ({ value: String(pg.id), label: pg.name })), + ]; + + return ( +
+ + + {/* Header */} +
+
+ {!capabilities.hasBackButton && ( + + )} +
+
+

{t('admin.bulkActions.title')}

+ + {total.toLocaleString()} + +
+

{t('admin.bulkActions.subtitle')}

+
+
+ +
+ + {/* Filters */} +
+ {/* Search */} +
+
+ handleSearchChange(e.target.value)} + placeholder={t('admin.bulkActions.filters.search')} + className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2.5 pl-10 pr-4 text-sm text-dark-100 outline-none transition-colors placeholder:text-dark-500 focus:border-accent-500/40 focus:shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]" + /> +
+ +
+
+
+ + {/* Filter dropdowns */} +
+ + + +
+
+ + {/* Table */} + {loading && users.length === 0 ? ( +
+
+
+ ) : users.length === 0 ? ( +
+
+ +
+

{t('admin.bulkActions.noResults')}

+
+ ) : ( +
0 && 'opacity-60', + )} + > +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ {offset + 1}–{Math.min(offset + limit, total)} / {total} +
+
+ + + {currentPage} / {totalPages} + + +
+
+ )} + + {/* Floating action bar */} + + + {/* Action modal */} + + + {/* Bottom spacer when action bar is visible */} + {selectedUserIds.length > 0 &&
} +
+ ); +} diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index f8b8f81..94470e4 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -333,6 +333,12 @@ const icons = { ), + 'list-checks': ( + + + + + ), search: ( @@ -412,6 +418,12 @@ const sections: AdminSection[] = [ gradient: 'linear-gradient(135deg, rgb(var(--color-accent-400)), rgb(var(--color-error-400)))', items: [ { name: 'admin.nav.users', icon: 'users', to: '/admin/users', permission: 'users:read' }, + { + name: 'admin.nav.bulkActions', + icon: 'list-checks', + to: '/admin/bulk-actions', + permission: 'users:read', + }, { name: 'admin.nav.tickets', icon: 'ticket',