mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
React 19's @types/react flags FormEvent as deprecated — quote: 'FormEvent doesn't actually exist. You probably meant to use ChangeEvent, InputEvent, SubmitEvent, or just SyntheticEvent'. All 17 call sites in this repo typed form onSubmit handlers and only called e.preventDefault(), so SyntheticEvent is the correct general replacement. No behavior change — pure type-level cleanup that clears the chronic deprecation hint that has been showing up after every edit to any form-bearing page.
2540 lines
89 KiB
TypeScript
2540 lines
89 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNavigate } from 'react-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import {
|
|
useReactTable,
|
|
getCoreRowModel,
|
|
flexRender,
|
|
type ColumnDef,
|
|
type RowSelectionState,
|
|
} from '@tanstack/react-table';
|
|
import { adminUsersApi, type UserListItem, type UserListItemSubscription } from '../api/adminUsers';
|
|
import { tariffsApi, type TariffListItem } from '../api/tariffs';
|
|
import { promocodesApi, type PromoGroup } from '../api/promocodes';
|
|
import { campaignsApi, type CampaignListItem } from '../api/campaigns';
|
|
import { partnerApi, type AdminPartnerItem } from '../api/partners';
|
|
import {
|
|
adminBulkActionsApi,
|
|
type BulkActionType,
|
|
type BulkActionParams,
|
|
type BulkActionResult,
|
|
type BulkProgressEvent,
|
|
} from '../api/adminBulkActions';
|
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
|
import { useCurrency } from '../hooks/useCurrency';
|
|
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
|
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 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 ============
|
|
|
|
const BackIcon = () => (
|
|
<svg
|
|
className="h-5 w-5 text-dark-400"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
|
</svg>
|
|
);
|
|
|
|
const SearchIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const ChevronLeftIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
|
</svg>
|
|
);
|
|
|
|
const ChevronRightIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
);
|
|
|
|
const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
const CheckIcon = () => (
|
|
<svg
|
|
className="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
</svg>
|
|
);
|
|
|
|
const XCloseIcon = () => (
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
);
|
|
|
|
const ChevronDownIcon = () => (
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
);
|
|
|
|
const ChevronExpandIcon = ({ expanded }: { expanded: boolean }) => (
|
|
<svg
|
|
className={cn('h-4 w-4 text-white transition-transform duration-200', expanded && 'rotate-180')}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
);
|
|
|
|
// ============ Action target helpers ============
|
|
|
|
const SUBSCRIPTION_LEVEL_ACTIONS: Set<BulkActionType> = new Set([
|
|
'extend_subscription',
|
|
'add_days',
|
|
'cancel_subscription',
|
|
'activate_subscription',
|
|
'change_tariff',
|
|
'add_traffic',
|
|
'set_devices',
|
|
'delete_subscription',
|
|
]);
|
|
|
|
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-warning-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-warning-400'
|
|
: 'bg-accent-400';
|
|
|
|
return (
|
|
<tr
|
|
className={cn(
|
|
'border-b border-dark-700/30 transition-colors',
|
|
isSelected ? 'bg-accent-500/8' : 'bg-dark-850/40 hover:bg-dark-800/60',
|
|
)}
|
|
>
|
|
{/* Checkbox cell */}
|
|
<td className="px-3 py-2">
|
|
{isMultiTariff && (
|
|
<div className="flex items-center justify-center">
|
|
<button
|
|
onClick={onToggleSelect}
|
|
className={cn(
|
|
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
|
|
isSelected
|
|
? 'border-accent-500 bg-accent-500 shadow-[0_0_8px_rgba(var(--color-accent-500),0.4)]'
|
|
: 'border-dark-500 bg-dark-700/60 hover:border-accent-500/50 hover:bg-dark-600/60',
|
|
)}
|
|
aria-label={
|
|
isSelected
|
|
? t('admin.bulkActions.deselectUser', { name: subscription.tariff_name || '' })
|
|
: t('admin.bulkActions.selectUser', { name: subscription.tariff_name || '' })
|
|
}
|
|
>
|
|
{isSelected && (
|
|
<svg
|
|
className="h-3 w-3 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={4}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</td>
|
|
|
|
{/* Subscription info — spans remaining columns */}
|
|
<td colSpan={7} className="px-3 py-2">
|
|
<div className="flex items-center gap-3 pl-9">
|
|
{/* Tariff name */}
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[10px] text-dark-500" aria-hidden="true">
|
|
<svg
|
|
className="h-3.5 w-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={1.5}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
<span className="text-xs font-semibold text-dark-200">
|
|
{subscription.tariff_name || '—'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<StatusBadge status={subscription.status} />
|
|
|
|
{/* Days remaining */}
|
|
<span className={cn('text-xs font-medium tabular-nums', daysColor)}>
|
|
{days}
|
|
<span className="ml-0.5 text-[10px] font-normal text-dark-500">
|
|
{t('admin.bulkActions.daysUnit')}
|
|
</span>
|
|
</span>
|
|
|
|
{/* Traffic progress */}
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[10px] tabular-nums text-dark-400">
|
|
{subscription.traffic_used_gb.toFixed(1)} {t('admin.bulkActions.trafficOf')}{' '}
|
|
{subscription.traffic_limit_gb} {t('admin.bulkActions.trafficGbUnit')}
|
|
</span>
|
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-dark-700/60">
|
|
<div
|
|
className={cn('h-full rounded-full transition-all duration-300', trafficBarColor)}
|
|
style={{ width: `${trafficPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Devices */}
|
|
<span className="flex items-center gap-1 text-xs text-dark-400">
|
|
<svg
|
|
className="h-3 w-3"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={1.5}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
|
/>
|
|
</svg>
|
|
{subscription.device_limit}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
// ============ Status badge ============
|
|
|
|
function StatusBadge({ status }: { status: string | null }) {
|
|
const { t } = useTranslation();
|
|
|
|
const config: Record<string, { class: string; labelKey: string }> = {
|
|
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-warning-500/30 bg-warning-500/15 text-warning-400',
|
|
labelKey: 'admin.bulkActions.statuses.trial',
|
|
},
|
|
limited: {
|
|
class: 'border-warning-500/30 bg-warning-500/15 text-warning-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 (
|
|
<span
|
|
className={cn(
|
|
'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium leading-tight',
|
|
c.class,
|
|
)}
|
|
>
|
|
{t(c.labelKey, status || '')}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ============ Progress Bar ============
|
|
|
|
function ProgressBar({ loading }: { loading: boolean }) {
|
|
const [progress, setProgress] = useState(0);
|
|
const [visible, setVisible] = useState(false);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval>>(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 (
|
|
<div className="absolute left-0 right-0 top-0 z-50 h-0.5 overflow-hidden rounded-full bg-dark-700/50">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-200 ease-out"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ 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 (
|
|
<div className={cn('relative', className)}>
|
|
<select
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="w-full appearance-none rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 pr-8 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40 focus:shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]"
|
|
>
|
|
{options.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<div className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-dark-500">
|
|
<ChevronDownIcon />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ Multi-Select Dropdown ============
|
|
|
|
interface MultiSelectOption {
|
|
value: number;
|
|
label: string;
|
|
}
|
|
|
|
interface MultiSelectDropdownProps {
|
|
options: MultiSelectOption[];
|
|
selected: number[];
|
|
onChange: (ids: number[]) => void;
|
|
placeholder: string;
|
|
className?: string;
|
|
}
|
|
|
|
function MultiSelectDropdown({
|
|
options,
|
|
selected,
|
|
onChange,
|
|
placeholder,
|
|
className,
|
|
}: MultiSelectDropdownProps) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [open]);
|
|
|
|
const buttonLabel = useMemo(() => {
|
|
if (selected.length === 0) return placeholder;
|
|
if (selected.length <= 2) {
|
|
return selected
|
|
.map((id) => options.find((o) => o.value === id)?.label)
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
}
|
|
return t('admin.bulkActions.filters.tariffsSelected', { count: selected.length });
|
|
}, [selected, options, placeholder, t]);
|
|
|
|
const handleToggle = (value: number) => {
|
|
if (selected.includes(value)) {
|
|
onChange(selected.filter((id) => id !== value));
|
|
} else {
|
|
onChange([...selected, value]);
|
|
}
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
onChange(options.map((o) => o.value));
|
|
};
|
|
|
|
const handleDeselectAll = () => {
|
|
onChange([]);
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className={cn('relative', className)}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(!open)}
|
|
className={cn(
|
|
'flex w-full items-center justify-between rounded-xl border bg-dark-800 px-3 py-2.5 text-left text-sm outline-none transition-colors',
|
|
open
|
|
? 'border-accent-500/40 shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]'
|
|
: 'border-dark-700',
|
|
selected.length > 0 ? 'text-dark-100' : 'text-dark-500',
|
|
)}
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
>
|
|
<span className="truncate">{buttonLabel}</span>
|
|
<div
|
|
className={cn('ml-2 shrink-0 text-dark-500 transition-transform', open && 'rotate-180')}
|
|
>
|
|
<ChevronDownIcon />
|
|
</div>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-2xl">
|
|
{/* Select all / Deselect all */}
|
|
<div className="flex items-center gap-1 border-b border-dark-700/50 px-3 py-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={handleSelectAll}
|
|
className="text-xs font-medium text-accent-400 transition-colors hover:text-accent-300"
|
|
>
|
|
{t('admin.bulkActions.filters.selectAll')}
|
|
</button>
|
|
<span className="text-dark-600">/</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleDeselectAll}
|
|
className="text-xs font-medium text-dark-400 transition-colors hover:text-dark-300"
|
|
>
|
|
{t('admin.bulkActions.filters.deselectAll')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Options */}
|
|
{options.map((option) => {
|
|
const isChecked = selected.includes(option.value);
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => handleToggle(option.value)}
|
|
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors hover:bg-dark-700/50"
|
|
role="option"
|
|
aria-selected={isChecked}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-all duration-150',
|
|
isChecked
|
|
? 'border-accent-500 bg-accent-500'
|
|
: 'border-dark-500 bg-dark-700/60',
|
|
)}
|
|
>
|
|
{isChecked && (
|
|
<svg
|
|
className="h-2.5 w-2.5 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={4}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M4.5 12.75l6 6 9-13.5"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</div>
|
|
<span className={cn('text-sm', isChecked ? 'text-dark-100' : 'text-dark-300')}>
|
|
{option.label}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ Progress View ============
|
|
|
|
function ProgressView({ progress }: { progress: ProgressState }) {
|
|
const { t } = useTranslation();
|
|
const logEndRef = useRef<HTMLDivElement>(null);
|
|
const percentage = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
|
|
|
|
useEffect(() => {
|
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [progress.log.length]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Progress bar */}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between text-sm">
|
|
<span className="font-medium text-dark-200">
|
|
{t('admin.bulkActions.progress.processed', {
|
|
current: progress.current,
|
|
total: progress.total,
|
|
})}
|
|
</span>
|
|
<span className="font-bold tabular-nums text-accent-400">{percentage}%</span>
|
|
</div>
|
|
<div className="h-2.5 overflow-hidden rounded-full bg-dark-700/60">
|
|
<div
|
|
className="h-full rounded-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300 ease-out"
|
|
style={{ width: `${percentage}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Counters */}
|
|
<div className="flex gap-4">
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="h-2 w-2 rounded-full bg-success-400" />
|
|
<span className="text-sm tabular-nums text-success-400">{progress.successCount}</span>
|
|
<span className="text-xs text-dark-500">{t('admin.bulkActions.progress.succeeded')}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="h-2 w-2 rounded-full bg-error-400" />
|
|
<span className="text-sm tabular-nums text-error-400">{progress.errorCount}</span>
|
|
<span className="text-xs text-dark-500">{t('admin.bulkActions.progress.failed')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Live log */}
|
|
{progress.log.length > 0 && (
|
|
<div className="max-h-48 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800/50 p-3">
|
|
<div className="space-y-1">
|
|
{progress.log.slice(-10).map((entry, idx) => (
|
|
<div key={idx} className="flex items-start gap-2 text-xs">
|
|
{entry.success ? (
|
|
<span className="mt-0.5 shrink-0 text-success-400" aria-hidden="true">
|
|
<svg
|
|
className="h-3 w-3"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M4.5 12.75l6 6 9-13.5"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
) : (
|
|
<span className="mt-0.5 shrink-0 text-error-400" aria-hidden="true">
|
|
<svg
|
|
className="h-3 w-3"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</span>
|
|
)}
|
|
<span className="font-mono text-dark-400">
|
|
{entry.username ? `@${entry.username}` : `#${entry.user_id}`}
|
|
</span>
|
|
<span className={entry.success ? 'text-dark-300' : 'text-error-300'}>
|
|
{entry.success
|
|
? entry.message || t('admin.bulkActions.progress.ok')
|
|
: entry.message || entry.error || t('admin.bulkActions.progress.errorGeneric')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
<div ref={logEndRef} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ 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 (
|
|
<div className="rounded-xl border border-error-500/20 bg-error-500/5">
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="flex w-full items-center justify-between px-4 py-3 text-left"
|
|
aria-expanded={expanded}
|
|
>
|
|
<span className="text-sm font-medium text-error-400">
|
|
{t('admin.bulkActions.errors.title', { count: result.error_count })}
|
|
</span>
|
|
<svg
|
|
className={cn(
|
|
'h-4 w-4 text-error-400 transition-transform duration-200',
|
|
expanded && 'rotate-180',
|
|
)}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</button>
|
|
{expanded && (
|
|
<div className="max-h-48 overflow-y-auto border-t border-error-500/20 px-4 py-3">
|
|
<div className="space-y-2">
|
|
{result.errors.map((err, idx) => (
|
|
<div key={idx} className="flex items-start gap-2 text-xs">
|
|
<span className="mt-0.5 shrink-0 text-error-400" aria-hidden="true">
|
|
<svg
|
|
className="h-3 w-3"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={3}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</span>
|
|
<span className="shrink-0 font-mono text-dark-400">
|
|
{err.username ? `@${err.username}` : `#${err.user_id}`}
|
|
</span>
|
|
<span className="text-error-300">{err.error}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ Action Modal ============
|
|
|
|
interface ActionModalProps {
|
|
modal: ModalState;
|
|
selectedCount: number;
|
|
tariffs: TariffListItem[];
|
|
promoGroups: PromoGroup[];
|
|
users: UserListItem[];
|
|
selectedSubscriptionIds: number[];
|
|
onClose: () => void;
|
|
onExecute: (params: BulkActionParams) => void;
|
|
}
|
|
|
|
function ActionModal({
|
|
modal,
|
|
selectedCount,
|
|
tariffs,
|
|
promoGroups,
|
|
users,
|
|
selectedSubscriptionIds,
|
|
onClose,
|
|
onExecute,
|
|
}: ActionModalProps) {
|
|
const { t } = useTranslation();
|
|
const [days, setDays] = useState(30);
|
|
const [tariffId, setTariffId] = useState<number>(tariffs[0]?.id ?? 0);
|
|
const [trafficGb, setTrafficGb] = useState(10);
|
|
const [balanceRub, setBalanceRub] = useState(100);
|
|
const [promoGroupId, setPromoGroupId] = useState<number | null>(promoGroups[0]?.id ?? null);
|
|
const [grantTariffId, setGrantTariffId] = useState<number>(tariffs[0]?.id ?? 0);
|
|
const [grantDays, setGrantDays] = useState(30);
|
|
const [deviceLimit, setDeviceLimit] = useState(1);
|
|
const [deleteFromPanel, setDeleteFromPanel] = useState(true);
|
|
const [forceDeleteActivePaid, setForceDeleteActivePaid] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (tariffs.length > 0 && tariffId === 0) {
|
|
setTariffId(tariffs[0].id);
|
|
}
|
|
if (tariffs.length > 0 && grantTariffId === 0) {
|
|
setGrantTariffId(tariffs[0].id);
|
|
}
|
|
}, [tariffs, tariffId, grantTariffId]);
|
|
|
|
useEffect(() => {
|
|
if (promoGroups.length > 0 && promoGroupId === null) {
|
|
setPromoGroupId(promoGroups[0].id);
|
|
}
|
|
}, [promoGroups, promoGroupId]);
|
|
|
|
// Reset modal-specific state when modal opens
|
|
useEffect(() => {
|
|
if (modal.open) {
|
|
if (modal.action === 'delete_user') {
|
|
setDeleteFromPanel(true);
|
|
}
|
|
if (modal.action === 'delete_subscription') {
|
|
setForceDeleteActivePaid(false);
|
|
}
|
|
}
|
|
}, [modal.open, modal.action]);
|
|
|
|
// Escape key handler — only when not loading
|
|
// Focus trap + scroll lock + Escape close (only while not loading).
|
|
const dialogRef = useFocusTrap<HTMLDivElement>(modal.open, {
|
|
onEscape: modal.loading ? undefined : onClose,
|
|
});
|
|
|
|
// Count active paid subscriptions among selected ones
|
|
const activePaidCount = useMemo(() => {
|
|
if (modal.action !== 'delete_subscription') return 0;
|
|
const selectedSubIdSet = new Set(selectedSubscriptionIds);
|
|
let count = 0;
|
|
for (const user of users) {
|
|
for (const sub of user.subscriptions ?? []) {
|
|
if (selectedSubIdSet.has(sub.id) && sub.status === 'active' && !sub.is_trial) {
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}, [modal.action, selectedSubscriptionIds, users]);
|
|
|
|
const isConfirmDisabled =
|
|
modal.loading ||
|
|
(modal.action === 'delete_subscription' && activePaidCount > 0 && !forceDeleteActivePaid);
|
|
|
|
if (!modal.open || !modal.action) return null;
|
|
|
|
const actionLabelKeys: Record<BulkActionType, string> = {
|
|
extend_subscription: 'admin.bulkActions.actions.extend',
|
|
add_days: 'admin.bulkActions.actions.extend',
|
|
cancel_subscription: 'admin.bulkActions.actions.cancel',
|
|
activate_subscription: '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',
|
|
grant_subscription: 'admin.bulkActions.actions.grantSubscription',
|
|
set_devices: 'admin.bulkActions.actions.setDevices',
|
|
delete_subscription: 'admin.bulkActions.actions.deleteSubscription',
|
|
delete_user: 'admin.bulkActions.actions.deleteUser',
|
|
};
|
|
|
|
const handleSubmit = () => {
|
|
const params: BulkActionParams = {};
|
|
switch (modal.action) {
|
|
case 'extend_subscription':
|
|
params.days = days;
|
|
break;
|
|
case 'change_tariff':
|
|
params.tariff_id = tariffId;
|
|
break;
|
|
case 'add_traffic':
|
|
params.traffic_gb = trafficGb;
|
|
break;
|
|
case 'add_balance':
|
|
params.amount_kopeks = Math.round(balanceRub * 100);
|
|
break;
|
|
case 'assign_promo_group':
|
|
params.promo_group_id = promoGroupId;
|
|
break;
|
|
case 'grant_subscription':
|
|
params.tariff_id = grantTariffId;
|
|
params.days = grantDays;
|
|
break;
|
|
case 'set_devices':
|
|
params.device_limit = deviceLimit;
|
|
break;
|
|
case 'delete_subscription':
|
|
params.force_delete_active_paid = forceDeleteActivePaid;
|
|
break;
|
|
case 'delete_user':
|
|
params.delete_from_panel = deleteFromPanel;
|
|
break;
|
|
}
|
|
onExecute(params);
|
|
};
|
|
|
|
const handleBackdropClick = () => {
|
|
if (!modal.loading) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const renderInputs = () => {
|
|
switch (modal.action) {
|
|
case 'extend_subscription':
|
|
return (
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
|
{t('admin.bulkActions.params.days')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
value={days}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
);
|
|
case 'change_tariff':
|
|
return (
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
|
{t('admin.bulkActions.params.tariff')}
|
|
</label>
|
|
<DropdownSelect
|
|
value={String(tariffId)}
|
|
options={tariffs.map((tt) => ({ value: String(tt.id), label: tt.name }))}
|
|
onChange={(v) => setTariffId(Number(v))}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'add_traffic':
|
|
return (
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
|
{t('admin.bulkActions.params.trafficGb')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={10000}
|
|
value={trafficGb}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
);
|
|
case 'add_balance':
|
|
return (
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
|
{t('admin.bulkActions.params.balanceRub')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={100000}
|
|
value={balanceRub}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
);
|
|
case 'assign_promo_group':
|
|
return (
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
|
{t('admin.bulkActions.params.promoGroup')}
|
|
</label>
|
|
<DropdownSelect
|
|
value={promoGroupId !== null ? String(promoGroupId) : 'null'}
|
|
options={[
|
|
{ value: 'null', label: t('admin.bulkActions.params.removePromoGroup') },
|
|
...promoGroups.map((pg) => ({ value: String(pg.id), label: pg.name })),
|
|
]}
|
|
onChange={(v) => setPromoGroupId(v === 'null' ? null : Number(v))}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'set_devices':
|
|
return (
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
|
{t('admin.bulkActions.params.deviceLimit')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={50}
|
|
value={deviceLimit}
|
|
onChange={(e) => setDeviceLimit(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"
|
|
/>
|
|
</div>
|
|
);
|
|
case 'grant_subscription':
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
|
{t('admin.bulkActions.params.tariff')}
|
|
</label>
|
|
<DropdownSelect
|
|
value={String(grantTariffId)}
|
|
options={tariffs.map((tt) => ({ value: String(tt.id), label: tt.name }))}
|
|
onChange={(v) => setGrantTariffId(Number(v))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
|
{t('admin.bulkActions.params.days')}
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={365}
|
|
value={grantDays}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
{/* Warning about users with existing subscriptions */}
|
|
<div className="rounded-xl border border-warning-500/20 bg-warning-500/5 px-3 py-2.5">
|
|
<p className="text-xs text-warning-400">
|
|
{t('admin.bulkActions.grantSubscription.warning')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'delete_subscription': {
|
|
const totalSelected = selectedSubscriptionIds.length;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
|
|
<p className="text-sm font-medium text-error-400">
|
|
{t('admin.bulkActions.deleteSubscription.warning')}
|
|
</p>
|
|
<p className="mt-1 text-xs text-error-300/70">
|
|
{t('admin.bulkActions.deleteSubscription.hint')}
|
|
</p>
|
|
</div>
|
|
{activePaidCount > 0 && (
|
|
<>
|
|
<div className="rounded-xl border border-warning-500/20 bg-warning-500/5 px-3 py-2.5">
|
|
<p className="text-sm font-medium text-warning-400">
|
|
{t('admin.bulkActions.deleteSubscription.activePaidWarning', {
|
|
count: activePaidCount,
|
|
total: totalSelected,
|
|
})}
|
|
</p>
|
|
</div>
|
|
<label className="inline-flex min-h-[44px] cursor-pointer items-center gap-2">
|
|
<button
|
|
onClick={() => setForceDeleteActivePaid((prev) => !prev)}
|
|
className={cn(
|
|
'flex h-6 w-6 items-center justify-center rounded-md border-2 transition-all duration-150',
|
|
forceDeleteActivePaid
|
|
? 'border-error-500 bg-error-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]'
|
|
: 'border-dark-500 bg-dark-700/60 hover:border-error-500/50 hover:bg-dark-600/60',
|
|
)}
|
|
aria-pressed={forceDeleteActivePaid}
|
|
>
|
|
{forceDeleteActivePaid && <CheckIcon />}
|
|
</button>
|
|
<span
|
|
className={cn(
|
|
'text-sm',
|
|
forceDeleteActivePaid ? 'font-medium text-error-400' : 'text-dark-400',
|
|
)}
|
|
>
|
|
{t('admin.bulkActions.deleteSubscription.forceDeleteConfirm')}
|
|
</span>
|
|
</label>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
case 'delete_user':
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-xl border border-error-500/20 bg-error-500/5 px-3 py-2.5">
|
|
<p className="text-sm font-medium text-error-400">
|
|
{t('admin.bulkActions.deleteUser.warning')}
|
|
</p>
|
|
<p className="mt-1 text-xs text-error-300/70">
|
|
{t('admin.bulkActions.deleteUser.hint')}
|
|
</p>
|
|
</div>
|
|
<label className="inline-flex min-h-[44px] cursor-pointer items-center gap-2">
|
|
<button
|
|
onClick={() => setDeleteFromPanel((prev) => !prev)}
|
|
className={cn(
|
|
'flex h-6 w-6 items-center justify-center rounded-md border-2 transition-all duration-150',
|
|
deleteFromPanel
|
|
? 'border-error-500 bg-error-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]'
|
|
: 'border-dark-500 bg-dark-700/60 hover:border-error-500/50 hover:bg-dark-600/60',
|
|
)}
|
|
aria-pressed={deleteFromPanel}
|
|
>
|
|
{deleteFromPanel && <CheckIcon />}
|
|
</button>
|
|
<span
|
|
className={cn(
|
|
'text-sm',
|
|
deleteFromPanel ? 'font-medium text-error-400' : 'text-dark-400',
|
|
)}
|
|
>
|
|
{t('admin.bulkActions.deleteUser.deleteFromPanel')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
|
style={{
|
|
paddingTop: 'max(1rem, env(safe-area-inset-top))',
|
|
paddingBottom: 'max(1rem, env(safe-area-inset-bottom))',
|
|
paddingLeft: 'max(1rem, env(safe-area-inset-left))',
|
|
paddingRight: 'max(1rem, env(safe-area-inset-right))',
|
|
}}
|
|
>
|
|
{/* Backdrop — no close on click during loading */}
|
|
<div
|
|
className="absolute inset-0 bg-dark-950/80 backdrop-blur-sm"
|
|
onClick={handleBackdropClick}
|
|
/>
|
|
|
|
{/* Modal — role/aria-modal live on the focus-trapped element so AT
|
|
announces and Tab cycles correctly. */}
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="bulk-action-modal-title"
|
|
tabIndex={-1}
|
|
className="relative max-h-[calc(100dvh-2rem)] w-full max-w-md overflow-y-auto rounded-2xl border border-dark-700 bg-dark-900 p-6 shadow-2xl"
|
|
>
|
|
{/* Header */}
|
|
<div className="mb-5 flex items-center justify-between">
|
|
<h3 id="bulk-action-modal-title" className="text-lg font-bold text-dark-100">
|
|
{t(actionLabelKeys[modal.action])}
|
|
</h3>
|
|
{!modal.loading && (
|
|
<button
|
|
onClick={onClose}
|
|
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
|
|
aria-label={t('common.close')}
|
|
>
|
|
<XCloseIcon />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress view during execution */}
|
|
{modal.loading && modal.progress ? (
|
|
<div className="space-y-4">
|
|
<ProgressView progress={modal.progress} />
|
|
<p className="text-center text-xs text-dark-500">
|
|
{t('admin.bulkActions.progress.doNotClose')}
|
|
</p>
|
|
</div>
|
|
) : modal.result ? (
|
|
/* Result view */
|
|
<div className="space-y-4">
|
|
{/* Summary line */}
|
|
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
|
<div className="mb-3 text-center text-sm font-semibold text-dark-100">
|
|
{t('admin.bulkActions.complete')}
|
|
</div>
|
|
{/* Colored summary */}
|
|
<div className="mb-3 text-center text-sm">
|
|
<span className="font-medium text-success-400">
|
|
{t('admin.bulkActions.progress.summarySuccess', {
|
|
count: modal.result.success_count,
|
|
})}
|
|
</span>
|
|
{modal.result.error_count > 0 && (
|
|
<>
|
|
<span className="mx-1.5 text-dark-600" aria-hidden="true">
|
|
/
|
|
</span>
|
|
<span className="font-medium text-error-400">
|
|
{t('admin.bulkActions.progress.summaryErrors', {
|
|
count: modal.result.error_count,
|
|
})}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-center gap-6">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-success-400">
|
|
{modal.result.success_count}
|
|
</div>
|
|
<div className="text-xs text-dark-400">
|
|
{t('admin.bulkActions.successCount', { count: modal.result.success_count })}
|
|
</div>
|
|
</div>
|
|
{modal.result.error_count > 0 && (
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-error-400">
|
|
{modal.result.error_count}
|
|
</div>
|
|
<div className="text-xs text-dark-400">
|
|
{t('admin.bulkActions.errorCount', { count: modal.result.error_count })}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Collapsible error details */}
|
|
<ErrorDetails result={modal.result} />
|
|
|
|
<button
|
|
onClick={onClose}
|
|
className="w-full rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
|
|
>
|
|
{t('common.close')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Affected count */}
|
|
<div className="mb-4 rounded-xl border border-accent-500/20 bg-accent-500/5 px-4 py-3">
|
|
<p className="text-sm text-dark-200">
|
|
{t('admin.bulkActions.selectedCount', { count: selectedCount })}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Action-specific inputs */}
|
|
<div className="mb-6">{renderInputs()}</div>
|
|
|
|
{/* Buttons */}
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={onClose}
|
|
disabled={modal.loading}
|
|
className="min-h-[44px] flex-1 rounded-xl border border-dark-700 bg-dark-800 px-4 py-2.5 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
|
>
|
|
{t('admin.bulkActions.cancel')}
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={isConfirmDisabled}
|
|
className="flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
|
>
|
|
{t('admin.bulkActions.confirm')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
// ============ Floating Action Bar ============
|
|
|
|
interface FloatingActionBarProps {
|
|
selectedUserCount: number;
|
|
selectedSubscriptionCount: number;
|
|
isMultiTariff: boolean;
|
|
totalVisibleSubscriptionCount: number;
|
|
onAction: (type: BulkActionType) => void;
|
|
onToggleAllSubscriptions: () => void;
|
|
}
|
|
|
|
function FloatingActionBar({
|
|
selectedUserCount,
|
|
selectedSubscriptionCount,
|
|
isMultiTariff,
|
|
totalVisibleSubscriptionCount,
|
|
onAction,
|
|
onToggleAllSubscriptions,
|
|
}: FloatingActionBarProps) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement>(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);
|
|
}, []);
|
|
|
|
const hasAnySelection = selectedUserCount > 0 || selectedSubscriptionCount > 0;
|
|
if (!hasAnySelection) return null;
|
|
|
|
const actions: ActionConfig[] = [
|
|
{
|
|
type: 'extend_subscription',
|
|
labelKey: 'admin.bulkActions.actions.extend',
|
|
icon: <span aria-hidden="true">+</span>,
|
|
colorClass: 'text-success-400 hover:bg-success-500/10',
|
|
},
|
|
{
|
|
type: 'activate_subscription',
|
|
labelKey: 'admin.bulkActions.actions.activate',
|
|
icon: <span aria-hidden="true">+</span>,
|
|
colorClass: 'text-success-400 hover:bg-success-500/10',
|
|
},
|
|
{
|
|
type: 'cancel_subscription',
|
|
labelKey: 'admin.bulkActions.actions.cancel',
|
|
icon: <span aria-hidden="true">-</span>,
|
|
colorClass: 'text-error-400 hover:bg-error-500/10',
|
|
},
|
|
{
|
|
type: 'change_tariff',
|
|
labelKey: 'admin.bulkActions.actions.changeTariff',
|
|
icon: <span aria-hidden="true">~</span>,
|
|
colorClass: 'text-accent-400 hover:bg-accent-500/10',
|
|
},
|
|
{
|
|
type: 'add_traffic',
|
|
labelKey: 'admin.bulkActions.actions.addTraffic',
|
|
icon: <span aria-hidden="true">+</span>,
|
|
colorClass: 'text-accent-400 hover:bg-accent-500/10',
|
|
},
|
|
{
|
|
type: 'set_devices',
|
|
labelKey: 'admin.bulkActions.actions.setDevices',
|
|
icon: (
|
|
<svg
|
|
className="h-3.5 w-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
|
/>
|
|
</svg>
|
|
),
|
|
colorClass: 'text-accent-400 hover:bg-accent-500/10',
|
|
},
|
|
{
|
|
type: 'delete_subscription',
|
|
labelKey: 'admin.bulkActions.actions.deleteSubscription',
|
|
icon: (
|
|
<svg
|
|
className="h-3.5 w-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
|
/>
|
|
</svg>
|
|
),
|
|
colorClass: 'text-error-400 hover:bg-error-500/10',
|
|
},
|
|
{
|
|
type: 'add_balance',
|
|
labelKey: 'admin.bulkActions.actions.addBalance',
|
|
icon: <span aria-hidden="true">$</span>,
|
|
colorClass: 'text-warning-400 hover:bg-warning-500/10',
|
|
},
|
|
{
|
|
type: 'grant_subscription',
|
|
labelKey: 'admin.bulkActions.actions.grantSubscription',
|
|
icon: <span aria-hidden="true">+</span>,
|
|
colorClass: 'text-success-400 hover:bg-success-500/10',
|
|
},
|
|
{
|
|
type: 'assign_promo_group',
|
|
labelKey: 'admin.bulkActions.actions.assignPromoGroup',
|
|
icon: <span aria-hidden="true">%</span>,
|
|
colorClass: 'text-accent-300 hover:bg-accent-300/10',
|
|
},
|
|
{
|
|
type: 'delete_user',
|
|
labelKey: 'admin.bulkActions.actions.deleteUser',
|
|
icon: (
|
|
<svg
|
|
className="h-3.5 w-3.5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M22 10.5h-6m-8.25-4.5a3.75 3.75 0 117.5 0 3.75 3.75 0 01-7.5 0zM1.5 21a8.25 8.25 0 0115 0"
|
|
/>
|
|
</svg>
|
|
),
|
|
colorClass: 'text-error-400 hover:bg-error-500/10',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="fixed inset-x-0 bottom-0 z-[9999] flex justify-center px-4 pb-[max(5rem,calc(4.5rem+env(safe-area-inset-bottom)))]">
|
|
<div
|
|
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 counts */}
|
|
<div className="flex items-center gap-2">
|
|
{selectedUserCount > 0 && (
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent-500/20 text-sm font-bold text-accent-400">
|
|
{selectedUserCount}
|
|
</div>
|
|
<span className="hidden text-xs font-medium text-dark-300 sm:inline">
|
|
{t('admin.bulkActions.usersSelected', { count: selectedUserCount })}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{isMultiTariff && selectedSubscriptionCount > 0 && (
|
|
<div className="flex items-center gap-1.5">
|
|
{selectedUserCount > 0 && <div className="mx-1 h-4 w-px bg-dark-700" />}
|
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-success-500/20 text-sm font-bold text-success-400">
|
|
{selectedSubscriptionCount}
|
|
</div>
|
|
<span className="hidden text-xs font-medium text-dark-300 sm:inline">
|
|
{t('admin.bulkActions.subscriptionsSelected', {
|
|
count: selectedSubscriptionCount,
|
|
})}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Toggle all subscriptions button */}
|
|
{isMultiTariff && totalVisibleSubscriptionCount > 0 && (
|
|
<>
|
|
<div className="mx-1 h-6 w-px bg-dark-700" />
|
|
<button
|
|
onClick={onToggleAllSubscriptions}
|
|
className="shrink-0 rounded-lg px-2.5 py-1.5 text-[11px] font-medium text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
|
>
|
|
{selectedSubscriptionCount === totalVisibleSubscriptionCount &&
|
|
selectedSubscriptionCount > 0
|
|
? t('admin.bulkActions.deselectAllSubs')
|
|
: t('admin.bulkActions.selectAllSubs')}
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<div className="mx-2 h-6 w-px bg-dark-700" />
|
|
|
|
{/* Actions dropdown */}
|
|
<div className="relative ml-auto">
|
|
<button
|
|
onClick={() => setOpen(!open)}
|
|
className="flex items-center gap-2 rounded-xl bg-accent-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
|
|
>
|
|
{t('common.actions')}
|
|
<ChevronDownIcon />
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-xl border border-dark-700 bg-dark-800 py-1.5 shadow-2xl">
|
|
{/* Subscription-level actions */}
|
|
{isMultiTariff && (
|
|
<div className="border-b border-dark-700/50 px-4 py-1.5">
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-dark-500">
|
|
{t('admin.bulkActions.subscriptionTarget')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{actions
|
|
.filter((a) => isSubscriptionLevelAction(a.type))
|
|
.map((a) => {
|
|
const count = isMultiTariff ? selectedSubscriptionCount : selectedUserCount;
|
|
const disabled = count === 0;
|
|
return (
|
|
<button
|
|
key={a.type}
|
|
onClick={() => {
|
|
if (disabled) return;
|
|
setOpen(false);
|
|
onAction(a.type);
|
|
}}
|
|
disabled={disabled}
|
|
className={cn(
|
|
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium transition-colors',
|
|
disabled ? 'cursor-not-allowed opacity-40' : a.colorClass,
|
|
)}
|
|
>
|
|
<span className="border-current/20 bg-current/5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-xs font-bold">
|
|
{a.icon}
|
|
</span>
|
|
{t(a.labelKey)}
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{/* User-level actions */}
|
|
{isMultiTariff && (
|
|
<div className="border-b border-t border-dark-700/50 px-4 py-1.5">
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-dark-500">
|
|
{t('admin.bulkActions.userTarget')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{actions
|
|
.filter((a) => !isSubscriptionLevelAction(a.type))
|
|
.map((a) => {
|
|
const disabled = selectedUserCount === 0;
|
|
return (
|
|
<button
|
|
key={a.type}
|
|
onClick={() => {
|
|
if (disabled) return;
|
|
setOpen(false);
|
|
onAction(a.type);
|
|
}}
|
|
disabled={disabled}
|
|
className={cn(
|
|
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium transition-colors',
|
|
disabled ? 'cursor-not-allowed opacity-40' : a.colorClass,
|
|
)}
|
|
>
|
|
<span className="border-current/20 bg-current/5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-xs font-bold">
|
|
{a.icon}
|
|
</span>
|
|
{t(a.labelKey)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============ Helpers ============
|
|
|
|
// ============ Main Page ============
|
|
|
|
export default function AdminBulkActions() {
|
|
const { t } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const { capabilities } = usePlatform();
|
|
const { formatWithCurrency } = useCurrency();
|
|
|
|
// Data
|
|
const [users, setUsers] = useState<UserListItem[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [tariffs, setTariffs] = useState<TariffListItem[]>([]);
|
|
const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]);
|
|
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
|
|
const [partners, setPartners] = useState<AdminPartnerItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Filters
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [committedSearch, setCommittedSearch] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<SubscriptionStatusFilter>('');
|
|
const [trialOnly, setTrialOnly] = useState(false);
|
|
const [tariffFilter, setTariffFilter] = useState<number[]>([]);
|
|
const [promoGroupFilter, setPromoGroupFilter] = useState('');
|
|
const [campaignFilter, setCampaignFilter] = useState('');
|
|
const [partnerFilter, setPartnerFilter] = useState('');
|
|
|
|
// Pagination
|
|
const [offset, setOffset] = useState(0);
|
|
const [limit, setLimit] = useState(50);
|
|
|
|
// Selection
|
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
|
const [subscriptionSelection, setSubscriptionSelection] = useState<Record<number, boolean>>({});
|
|
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
|
|
|
|
// Modal
|
|
const [modal, setModal] = useState<ModalState>({
|
|
open: false,
|
|
action: null,
|
|
loading: false,
|
|
result: null,
|
|
progress: null,
|
|
});
|
|
|
|
// Debounce timer ref
|
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
// ---- Multi-tariff detection ----
|
|
const isMultiTariff = useMemo(
|
|
() => users.some((u) => (u.subscriptions?.length ?? 0) > 1),
|
|
[users],
|
|
);
|
|
|
|
// ---- Data loading ----
|
|
|
|
// React Query owns network state; existing setters stay so downstream
|
|
// selection / handler code does not need to change.
|
|
const usersQuery = useQuery({
|
|
queryKey: [
|
|
'admin-bulk-users',
|
|
offset,
|
|
limit,
|
|
committedSearch,
|
|
statusFilter,
|
|
tariffFilter.join(','),
|
|
promoGroupFilter,
|
|
campaignFilter,
|
|
partnerFilter,
|
|
] as const,
|
|
queryFn: () => {
|
|
const params: Record<string, unknown> = { offset, limit };
|
|
if (committedSearch) params.search = committedSearch;
|
|
if (statusFilter) params.subscription_status = statusFilter;
|
|
if (tariffFilter.length > 0) params.tariff_id = tariffFilter.join(',');
|
|
if (promoGroupFilter) params.promo_group_id = Number(promoGroupFilter);
|
|
if (campaignFilter) params.campaign_id = Number(campaignFilter);
|
|
if (partnerFilter) params.partner_id = Number(partnerFilter);
|
|
return adminUsersApi.getUsers(params as Parameters<typeof adminUsersApi.getUsers>[0]);
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!usersQuery.data) return;
|
|
setUsers(usersQuery.data.users);
|
|
setTotal(usersQuery.data.total);
|
|
// Auto-expand all users who have subscriptions
|
|
const autoExpand: Record<number, boolean> = {};
|
|
for (const u of usersQuery.data.users) {
|
|
if ((u.subscriptions?.length ?? 0) > 0) {
|
|
autoExpand[u.id] = true;
|
|
}
|
|
}
|
|
setExpandedRows(autoExpand);
|
|
}, [usersQuery.data]);
|
|
|
|
useEffect(() => {
|
|
setLoading(usersQuery.isFetching);
|
|
}, [usersQuery.isFetching]);
|
|
|
|
const loadUsers = useCallback(
|
|
async () => {
|
|
await usersQuery.refetch();
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[usersQuery.refetch],
|
|
);
|
|
|
|
// Static lookup tables — long staleTime since these change rarely.
|
|
const tariffsLookupQuery = useQuery({
|
|
queryKey: ['admin-bulk-tariffs-lookup'] as const,
|
|
queryFn: () => tariffsApi.getTariffs(true),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
const promoGroupsLookupQuery = useQuery({
|
|
queryKey: ['admin-bulk-promo-groups-lookup'] as const,
|
|
queryFn: () => promocodesApi.getPromoGroups({ limit: 200 }),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
const campaignsLookupQuery = useQuery({
|
|
queryKey: ['admin-bulk-campaigns-lookup'] as const,
|
|
queryFn: () => campaignsApi.getCampaigns(true, 0, 100),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
const partnersLookupQuery = useQuery({
|
|
queryKey: ['admin-bulk-partners-lookup'] as const,
|
|
queryFn: () => partnerApi.getPartners({ limit: 100 }),
|
|
staleTime: 5 * 60 * 1000,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (tariffsLookupQuery.data) setTariffs(tariffsLookupQuery.data.tariffs);
|
|
}, [tariffsLookupQuery.data]);
|
|
useEffect(() => {
|
|
if (promoGroupsLookupQuery.data) setPromoGroups(promoGroupsLookupQuery.data.items);
|
|
}, [promoGroupsLookupQuery.data]);
|
|
useEffect(() => {
|
|
if (campaignsLookupQuery.data) setCampaigns(campaignsLookupQuery.data.campaigns);
|
|
}, [campaignsLookupQuery.data]);
|
|
useEffect(() => {
|
|
if (partnersLookupQuery.data) setPartners(partnersLookupQuery.data.items);
|
|
}, [partnersLookupQuery.data]);
|
|
|
|
// ---- Handlers ----
|
|
|
|
const clearAllSelections = useCallback(() => {
|
|
setRowSelection({});
|
|
setSubscriptionSelection({});
|
|
setExpandedRows({});
|
|
}, []);
|
|
|
|
const handleSearchChange = (value: string) => {
|
|
setSearchInput(value);
|
|
clearTimeout(searchTimerRef.current);
|
|
searchTimerRef.current = setTimeout(() => {
|
|
setOffset(0);
|
|
setCommittedSearch(value);
|
|
}, 400);
|
|
};
|
|
|
|
// Cleanup debounce on unmount
|
|
useEffect(() => {
|
|
return () => clearTimeout(searchTimerRef.current);
|
|
}, []);
|
|
|
|
const handleSearchSubmit = (e: React.SyntheticEvent) => {
|
|
e.preventDefault();
|
|
clearTimeout(searchTimerRef.current);
|
|
setOffset(0);
|
|
setCommittedSearch(searchInput);
|
|
};
|
|
|
|
const handleStatusFilterChange = (v: string) => {
|
|
setStatusFilter(v as SubscriptionStatusFilter);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleTariffFilterChange = (ids: number[]) => {
|
|
setTariffFilter(ids);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handlePromoGroupFilterChange = (v: string) => {
|
|
setPromoGroupFilter(v);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleCampaignFilterChange = (v: string) => {
|
|
setCampaignFilter(v);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handlePartnerFilterChange = (v: string) => {
|
|
setPartnerFilter(v);
|
|
setOffset(0);
|
|
};
|
|
|
|
const handleRefresh = () => {
|
|
loadUsers();
|
|
};
|
|
|
|
const selectedUserIds = useMemo(() => {
|
|
return Object.keys(rowSelection)
|
|
.filter((k) => rowSelection[k])
|
|
.map(Number)
|
|
.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 getFilteredSubs = useCallback(
|
|
(subs: UserListItemSubscription[]): UserListItemSubscription[] => {
|
|
let result = subs;
|
|
if (tariffFilter.length > 0) {
|
|
result = result.filter((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id));
|
|
}
|
|
if (trialOnly) {
|
|
result = result.filter((s) => s.is_trial);
|
|
}
|
|
return result;
|
|
},
|
|
[tariffFilter, trialOnly],
|
|
);
|
|
|
|
// Selection-related derivations live here so the columns useMemo (below)
|
|
// can list them as deps without forward-reference warnings.
|
|
// When multiple tariffs are selected, filter users client-side
|
|
// (server only supports single tariff_id filter)
|
|
const filteredUsers = useMemo(() => {
|
|
let result = users;
|
|
if (trialOnly) {
|
|
result = result.filter(
|
|
(u) => u.subscription_is_trial || (u.subscriptions ?? []).some((s) => s.status === 'trial'),
|
|
);
|
|
}
|
|
return result;
|
|
}, [users, trialOnly]);
|
|
|
|
const allVisibleSubscriptionIds = useMemo(() => {
|
|
const ids: number[] = [];
|
|
for (const user of filteredUsers) {
|
|
const subs = user.subscriptions ?? [];
|
|
const filtered = getFilteredSubs(subs);
|
|
for (const sub of filtered) {
|
|
ids.push(sub.id);
|
|
}
|
|
}
|
|
return ids;
|
|
}, [filteredUsers, getFilteredSubs]);
|
|
|
|
const toggleAllSubscriptions = useCallback(() => {
|
|
const allSelected =
|
|
allVisibleSubscriptionIds.length > 0 &&
|
|
allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]);
|
|
|
|
if (allSelected) {
|
|
const next: Record<number, boolean> = {};
|
|
for (const key of Object.keys(subscriptionSelection)) {
|
|
const id = Number(key);
|
|
if (!allVisibleSubscriptionIds.includes(id)) {
|
|
next[id] = subscriptionSelection[id];
|
|
}
|
|
}
|
|
setSubscriptionSelection(next);
|
|
} else {
|
|
const next = { ...subscriptionSelection };
|
|
for (const id of allVisibleSubscriptionIds) {
|
|
next[id] = true;
|
|
}
|
|
setSubscriptionSelection(next);
|
|
}
|
|
|
|
// Auto-expand rows that have filtered subs
|
|
const expanded: Record<number, boolean> = { ...expandedRows };
|
|
for (const user of users) {
|
|
const subs = user.subscriptions ?? [];
|
|
const filtered = getFilteredSubs(subs);
|
|
if (filtered.length > 1) {
|
|
expanded[user.id] = true;
|
|
}
|
|
}
|
|
setExpandedRows(expanded);
|
|
}, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]);
|
|
|
|
const handleOpenAction = (type: BulkActionType) => {
|
|
setModal({ open: true, action: type, loading: false, result: null, progress: null });
|
|
};
|
|
|
|
const handleExecuteAction = async (params: BulkActionParams) => {
|
|
if (!modal.action) return;
|
|
|
|
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,
|
|
progress: {
|
|
current: 0,
|
|
total: totalCount,
|
|
successCount: 0,
|
|
errorCount: 0,
|
|
log: [],
|
|
},
|
|
}));
|
|
|
|
const requestPayload = isSubAction
|
|
? { action: modal.action, subscription_ids: targetIds, params }
|
|
: { action: modal.action, user_ids: targetIds, params };
|
|
|
|
try {
|
|
await adminBulkActionsApi.executeWithStream(requestPayload, (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,
|
|
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) => {
|
|
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,
|
|
skipped_count: 0,
|
|
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;
|
|
});
|
|
|
|
loadUsers();
|
|
} catch {
|
|
setModal((prev) => ({
|
|
...prev,
|
|
loading: false,
|
|
progress: null,
|
|
result: {
|
|
success: false,
|
|
total: totalCount,
|
|
success_count: prev.progress?.successCount ?? 0,
|
|
error_count: totalCount - (prev.progress?.successCount ?? 0),
|
|
skipped_count: 0,
|
|
errors: [],
|
|
},
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
if (modal.loading) return;
|
|
if (modal.result) {
|
|
clearAllSelections();
|
|
}
|
|
setModal({ open: false, action: null, loading: false, result: null, progress: null });
|
|
};
|
|
|
|
// ---- TanStack Table ----
|
|
|
|
const columns = useMemo<ColumnDef<UserListItem>[]>(
|
|
() => [
|
|
{
|
|
id: 'select',
|
|
size: 56,
|
|
header: ({ table }) => {
|
|
const allSubsSelected =
|
|
allVisibleSubscriptionIds.length > 0 &&
|
|
allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]);
|
|
const someSubsSelected =
|
|
!allSubsSelected && allVisibleSubscriptionIds.some((id) => subscriptionSelection[id]);
|
|
return (
|
|
<div className="flex items-center justify-center gap-1.5">
|
|
{/* Select all users */}
|
|
<button
|
|
onClick={table.getToggleAllRowsSelectedHandler()}
|
|
className={cn(
|
|
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
|
|
table.getIsAllRowsSelected()
|
|
? 'border-accent-500 bg-accent-500 shadow-[0_0_8px_rgba(var(--color-accent-500),0.4)]'
|
|
: table.getIsSomeRowsSelected()
|
|
? 'border-accent-500 bg-accent-500/30'
|
|
: 'border-dark-500 bg-dark-700/60 hover:border-accent-500/50 hover:bg-dark-600/60',
|
|
)}
|
|
aria-label={t('admin.bulkActions.selectAll')}
|
|
title={t('admin.bulkActions.selectAll')}
|
|
>
|
|
{table.getIsAllRowsSelected() && <CheckIcon />}
|
|
{table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && (
|
|
<div className="h-0.5 w-2 rounded-full bg-white" />
|
|
)}
|
|
</button>
|
|
{/* Select all subscriptions */}
|
|
{isMultiTariff && allVisibleSubscriptionIds.length > 0 && (
|
|
<button
|
|
onClick={toggleAllSubscriptions}
|
|
className={cn(
|
|
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
|
|
allSubsSelected
|
|
? 'border-success-500 bg-success-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]'
|
|
: someSubsSelected
|
|
? 'border-success-500 bg-success-500/30'
|
|
: 'border-dark-500 bg-dark-700/60 hover:border-success-500/50 hover:bg-dark-600/60',
|
|
)}
|
|
aria-label={t('admin.bulkActions.selectAllSubs')}
|
|
title={t('admin.bulkActions.selectAllSubs')}
|
|
>
|
|
{allSubsSelected && <CheckIcon />}
|
|
{someSubsSelected && <div className="h-0.5 w-2 rounded-full bg-white" />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
cell: ({ row }) => {
|
|
const userName =
|
|
row.original.full_name || row.original.username || String(row.original.telegram_id);
|
|
return (
|
|
<div className="flex items-center justify-center">
|
|
<button
|
|
onClick={row.getToggleSelectedHandler()}
|
|
className={cn(
|
|
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
|
|
row.getIsSelected()
|
|
? 'border-accent-500 bg-accent-500 shadow-[0_0_8px_rgba(var(--color-accent-500),0.4)]'
|
|
: 'border-dark-500 bg-dark-700/60 hover:border-accent-500/50 hover:bg-dark-600/60',
|
|
)}
|
|
aria-label={
|
|
row.getIsSelected()
|
|
? t('admin.bulkActions.deselectUser', { name: userName })
|
|
: t('admin.bulkActions.selectUser', { name: userName })
|
|
}
|
|
>
|
|
{row.getIsSelected() && <CheckIcon />}
|
|
</button>
|
|
</div>
|
|
);
|
|
},
|
|
enableSorting: false,
|
|
},
|
|
{
|
|
id: 'user',
|
|
accessorFn: (row) => row.full_name,
|
|
header: t('admin.bulkActions.columns.user'),
|
|
size: 200,
|
|
cell: ({ row }) => {
|
|
const user = row.original;
|
|
const filteredSubs = getFilteredSubs(user.subscriptions ?? []);
|
|
const subCount = filteredSubs.length;
|
|
const canExpand = isMultiTariff && subCount > 0;
|
|
const isExpanded = expandedRows[user.id] ?? false;
|
|
return (
|
|
<div className="flex items-center gap-2.5">
|
|
{canExpand ? (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleExpandRow(user.id);
|
|
}}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 transition-transform"
|
|
aria-label={
|
|
isExpanded
|
|
? t('admin.bulkActions.collapseSubscriptions')
|
|
: t('admin.bulkActions.expandSubscriptions')
|
|
}
|
|
aria-expanded={isExpanded}
|
|
>
|
|
<ChevronExpandIcon expanded={isExpanded} />
|
|
</button>
|
|
) : (
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-[10px] font-medium text-white">
|
|
{user.first_name?.[0] || user.username?.[0] || '?'}
|
|
</div>
|
|
)}
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="truncate text-xs font-medium text-dark-100">
|
|
{user.full_name}
|
|
</span>
|
|
{canExpand && (
|
|
<span className="shrink-0 rounded-md bg-dark-700/60 px-1.5 py-0.5 text-[9px] font-medium tabular-nums text-dark-400">
|
|
{subCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="truncate text-[10px] leading-tight text-dark-500">
|
|
{user.username ? `@${user.username}` : `ID: ${user.telegram_id}`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
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 <span className="text-xs text-dark-500">-</span>;
|
|
}
|
|
return <StatusBadge status={user.subscription_status} />;
|
|
},
|
|
},
|
|
{
|
|
id: 'tariff',
|
|
accessorFn: (row) => row.tariff_name,
|
|
header: t('admin.bulkActions.columns.tariff'),
|
|
size: 160,
|
|
cell: ({ row }) => {
|
|
const user = row.original;
|
|
const subs = user.subscriptions ?? [];
|
|
const tariffNames = subs
|
|
.map((s) => s.tariff_name)
|
|
.filter((name): name is string => !!name);
|
|
const uniqueNames = [...new Set(tariffNames)];
|
|
|
|
if (uniqueNames.length === 0) {
|
|
if (!user.tariff_name) {
|
|
return <span className="text-xs text-dark-500">—</span>;
|
|
}
|
|
return <span className="text-xs text-dark-200">{user.tariff_name}</span>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{uniqueNames.map((name) => (
|
|
<span
|
|
key={name}
|
|
className="inline-flex rounded-md border border-dark-600/40 bg-dark-700/40 px-1.5 py-0.5 text-[10px] font-medium text-dark-200"
|
|
>
|
|
{name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'balance',
|
|
accessorKey: 'balance_rubles',
|
|
header: t('admin.bulkActions.columns.balance'),
|
|
size: 100,
|
|
cell: ({ getValue }) => (
|
|
<span className="text-xs font-medium text-dark-200">
|
|
{formatWithCurrency(getValue() as number)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
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 ? (
|
|
<span className="inline-flex rounded-lg border border-accent-500/20 bg-accent-500/5 px-2 py-0.5 text-[10px] font-medium text-accent-400">
|
|
{name}
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-dark-500">-</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'total_spent',
|
|
accessorKey: 'total_spent_kopeks',
|
|
header: t('admin.bulkActions.columns.spent'),
|
|
size: 100,
|
|
cell: ({ getValue }) => {
|
|
const kopeks = getValue() as number;
|
|
return (
|
|
<span className="text-xs text-dark-300">
|
|
{kopeks > 0 ? formatWithCurrency(kopeks / 100) : '-'}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[
|
|
t,
|
|
formatWithCurrency,
|
|
expandedRows,
|
|
toggleExpandRow,
|
|
getFilteredSubs,
|
|
allVisibleSubscriptionIds,
|
|
isMultiTariff,
|
|
subscriptionSelection,
|
|
toggleAllSubscriptions,
|
|
],
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data: filteredUsers,
|
|
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: 'limited', label: t('admin.bulkActions.statuses.limited') },
|
|
{ value: 'disabled', label: t('admin.bulkActions.statuses.disabled') },
|
|
];
|
|
|
|
const tariffMultiOptions: MultiSelectOption[] = tariffs.map((tt) => ({
|
|
value: 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 })),
|
|
];
|
|
|
|
const campaignOptions: DropdownOption[] = [
|
|
{ value: '', label: t('admin.bulkActions.filters.allCampaigns') },
|
|
...campaigns.map((c) => ({ value: String(c.id), label: c.name })),
|
|
];
|
|
|
|
const partnerOptions: DropdownOption[] = [
|
|
{ value: '', label: t('admin.bulkActions.filters.allPartners') },
|
|
...partners.map((p) => ({
|
|
value: String(p.user_id),
|
|
label: p.username ? `@${p.username}` : p.first_name || String(p.telegram_id),
|
|
})),
|
|
];
|
|
|
|
return (
|
|
<div className="relative animate-fade-in">
|
|
<ProgressBar loading={loading} />
|
|
|
|
{/* Header */}
|
|
<div className="mb-5 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{!capabilities.hasBackButton && (
|
|
<button
|
|
onClick={() => navigate('/admin')}
|
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
|
aria-label={t('common.back')}
|
|
>
|
|
<BackIcon />
|
|
</button>
|
|
)}
|
|
<div>
|
|
<div className="flex items-center gap-2.5">
|
|
<h1 className="text-xl font-bold text-dark-100">{t('admin.bulkActions.title')}</h1>
|
|
<span className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-0.5 text-xs font-medium tabular-nums text-dark-400">
|
|
{total.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-dark-400">{t('admin.bulkActions.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
|
aria-label={t('common.refresh')}
|
|
>
|
|
<RefreshIcon className={loading ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="mb-4 flex flex-col gap-3">
|
|
{/* Search */}
|
|
<form onSubmit={handleSearchSubmit}>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={searchInput}
|
|
onChange={(e) => 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)]"
|
|
/>
|
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
|
<SearchIcon />
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Filter dropdowns + trial toggle */}
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
<DropdownSelect
|
|
value={statusFilter}
|
|
options={statusOptions}
|
|
onChange={handleStatusFilterChange}
|
|
/>
|
|
<MultiSelectDropdown
|
|
options={tariffMultiOptions}
|
|
selected={tariffFilter}
|
|
onChange={handleTariffFilterChange}
|
|
placeholder={t('admin.bulkActions.filters.allTariffs')}
|
|
/>
|
|
<DropdownSelect
|
|
value={promoGroupFilter}
|
|
options={promoGroupOptions}
|
|
onChange={handlePromoGroupFilterChange}
|
|
/>
|
|
</div>
|
|
|
|
{/* Campaign & Partner filters */}
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<DropdownSelect
|
|
value={campaignFilter}
|
|
options={campaignOptions}
|
|
onChange={handleCampaignFilterChange}
|
|
/>
|
|
<DropdownSelect
|
|
value={partnerFilter}
|
|
options={partnerOptions}
|
|
onChange={handlePartnerFilterChange}
|
|
/>
|
|
</div>
|
|
|
|
{/* Trial filter checkbox */}
|
|
<label className="inline-flex cursor-pointer items-center gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setTrialOnly((prev) => !prev);
|
|
setOffset(0);
|
|
}}
|
|
className={cn(
|
|
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
|
|
trialOnly
|
|
? 'border-warning-500 bg-warning-500 shadow-[0_0_8px_rgba(245,158,11,0.4)]'
|
|
: 'border-dark-500 bg-dark-700/60 hover:border-warning-500/50 hover:bg-dark-600/60',
|
|
)}
|
|
aria-pressed={trialOnly}
|
|
>
|
|
{trialOnly && <CheckIcon />}
|
|
</button>
|
|
<span
|
|
className={cn('text-sm', trialOnly ? 'font-medium text-warning-400' : 'text-dark-400')}
|
|
>
|
|
{t('admin.bulkActions.filters.trialOnly')}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
{loading && users.length === 0 ? (
|
|
<div className="flex justify-center py-16">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
|
</div>
|
|
) : users.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16">
|
|
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-700/50 bg-dark-800/40 text-dark-500">
|
|
<SearchIcon />
|
|
</div>
|
|
<p className="text-sm font-medium text-dark-300">{t('admin.bulkActions.noResults')}</p>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
'transition-opacity duration-200',
|
|
loading && users.length > 0 && 'opacity-60',
|
|
)}
|
|
>
|
|
<div className="overflow-x-auto rounded-xl border border-dark-700">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80">
|
|
{headerGroup.headers.map((header) => (
|
|
<th
|
|
key={header.id}
|
|
className="whitespace-nowrap px-3 py-2.5 text-xs font-medium text-dark-400"
|
|
style={{ width: header.getSize() }}
|
|
>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</thead>
|
|
<tbody>
|
|
{table.getRowModel().rows.map((row) => {
|
|
const user = row.original;
|
|
const filteredSubs = getFilteredSubs(user.subscriptions ?? []);
|
|
const canExpand = isMultiTariff && filteredSubs.length > 0;
|
|
const isExpanded = expandedRows[user.id] ?? false;
|
|
|
|
return (
|
|
<React.Fragment key={row.id}>
|
|
{/* User row */}
|
|
<tr
|
|
className={cn(
|
|
'border-b transition-colors',
|
|
canExpand && isExpanded ? 'border-dark-700/30' : 'border-dark-700/50',
|
|
row.getIsSelected() ? 'bg-accent-500/5' : 'hover:bg-dark-800/50',
|
|
)}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<td
|
|
key={cell.id}
|
|
className="px-3 py-2.5"
|
|
style={{ width: cell.column.getSize() }}
|
|
>
|
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
|
|
{/* Subscription sub-rows (only when expanded and multi-sub) */}
|
|
{canExpand &&
|
|
isExpanded &&
|
|
filteredSubs.map((sub) => (
|
|
<SubscriptionSubRow
|
|
key={`sub-${sub.id}`}
|
|
subscription={sub}
|
|
isSelected={subscriptionSelection[sub.id] ?? false}
|
|
onToggleSelect={() => toggleSubscriptionSelection(sub.id)}
|
|
isMultiTariff={isMultiTariff}
|
|
/>
|
|
))}
|
|
|
|
{/* Single subscription — auto-select with user (no separate sub-row) */}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination + per-page selector */}
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-dark-400">
|
|
{offset + 1}–{Math.min(offset + limit, total)} / {total}
|
|
</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-dark-500">{t('admin.bulkActions.perPage')}</span>
|
|
<select
|
|
value={limit}
|
|
onChange={(e) => {
|
|
setLimit(Number(e.target.value));
|
|
setOffset(0);
|
|
}}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-xs text-dark-200 outline-none transition-colors focus:border-accent-500/40"
|
|
>
|
|
{[25, 50, 100, 200].map((n) => (
|
|
<option key={n} value={n}>
|
|
{n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => {
|
|
setOffset(Math.max(0, offset - limit));
|
|
}}
|
|
disabled={offset === 0}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
|
aria-label={t('common.back')}
|
|
>
|
|
<ChevronLeftIcon />
|
|
</button>
|
|
<span className="px-3 py-2 text-sm text-dark-300">
|
|
{currentPage} / {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
setOffset(offset + limit);
|
|
}}
|
|
disabled={offset + limit >= total}
|
|
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
|
aria-label={t('common.next')}
|
|
>
|
|
<ChevronRightIcon />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom spacer to prevent floating bar from covering pagination */}
|
|
{(selectedUserIds.length > 0 || selectedSubscriptionIds.length > 0) && (
|
|
<div className="h-24" />
|
|
)}
|
|
|
|
{/* Floating action bar — portal to body for correct fixed positioning */}
|
|
{createPortal(
|
|
<FloatingActionBar
|
|
selectedUserCount={selectedUserIds.length}
|
|
selectedSubscriptionCount={selectedSubscriptionIds.length}
|
|
isMultiTariff={isMultiTariff}
|
|
totalVisibleSubscriptionCount={allVisibleSubscriptionIds.length}
|
|
onAction={handleOpenAction}
|
|
onToggleAllSubscriptions={toggleAllSubscriptions}
|
|
/>,
|
|
document.body,
|
|
)}
|
|
|
|
{/* Action modal */}
|
|
<ActionModal
|
|
modal={modal}
|
|
selectedCount={
|
|
modal.action && isMultiTariff && isSubscriptionLevelAction(modal.action)
|
|
? selectedSubscriptionIds.length
|
|
: selectedUserIds.length
|
|
}
|
|
tariffs={tariffs}
|
|
promoGroups={promoGroups}
|
|
users={users}
|
|
selectedSubscriptionIds={selectedSubscriptionIds}
|
|
onClose={handleCloseModal}
|
|
onExecute={handleExecuteAction}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|