mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
feat(i18n): migrate all hardcoded Russian text to i18n
- Replace all hardcoded Russian strings with t() calls across 30+ files - Add ~500 new translation keys to all 4 locales (ru, en, zh, fa) - Convert module-level config objects to labelKey pattern - Remove Russian fallbacks from t() calls (fallbackLng handles it) - Replace DeepLinkRedirect custom i18n with standard t() calls - Fix subscription.servers key collision (string vs object)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import {
|
||||
adminUsersApi,
|
||||
@@ -132,6 +134,7 @@ interface UserRowProps {
|
||||
}
|
||||
|
||||
function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(user)}
|
||||
@@ -165,10 +168,10 @@ function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
}`}
|
||||
>
|
||||
{user.subscription_status === 'active'
|
||||
? 'Подписка'
|
||||
? t('admin.users.status.subscription')
|
||||
: user.subscription_status === 'trial'
|
||||
? 'Триал'
|
||||
: 'Истекла'}
|
||||
? t('admin.users.status.trial')
|
||||
: t('admin.users.status.expired')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,7 +181,9 @@ function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-medium text-dark-100">{formatAmount(user.balance_rubles)}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{user.purchase_count > 0 ? `${user.purchase_count} покупок` : 'Нет покупок'}
|
||||
{user.purchase_count > 0
|
||||
? t('admin.users.purchaseCount', { count: user.purchase_count })
|
||||
: t('admin.users.noPurchases')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +201,10 @@ interface UserDetailModalProps {
|
||||
}
|
||||
|
||||
function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'subscription' | 'balance' | 'sync'>('info');
|
||||
@@ -259,7 +267,11 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
const amount = Math.abs(parseFloat(balanceAmount) * 100);
|
||||
await adminUsersApi.updateBalance(userId, {
|
||||
amount_kopeks: isAdd ? amount : -amount,
|
||||
description: balanceDescription || (isAdd ? 'Начисление админом' : 'Списание админом'),
|
||||
description:
|
||||
balanceDescription ||
|
||||
(isAdd
|
||||
? t('admin.users.detail.balance.addByAdmin')
|
||||
: t('admin.users.detail.balance.subtractByAdmin')),
|
||||
});
|
||||
await loadUser();
|
||||
setBalanceAmount('');
|
||||
@@ -299,7 +311,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
};
|
||||
|
||||
const handleBlockUser = async () => {
|
||||
if (!confirm('Заблокировать пользователя?')) return;
|
||||
if (!confirm(t('admin.users.confirm.block'))) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.blockUser(userId);
|
||||
@@ -358,7 +370,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('ru-RU', {
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
@@ -414,10 +426,10 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' && 'Информация'}
|
||||
{tab === 'subscription' && 'Подписка'}
|
||||
{tab === 'balance' && 'Баланс'}
|
||||
{tab === 'sync' && 'Синхронизация'}
|
||||
{tab === 'info' && t('admin.users.detail.tabs.info')}
|
||||
{tab === 'subscription' && t('admin.users.detail.tabs.subscription')}
|
||||
{tab === 'balance' && t('admin.users.detail.tabs.balance')}
|
||||
{tab === 'sync' && t('admin.users.detail.tabs.sync')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -429,7 +441,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-900/50 p-3">
|
||||
<span className="text-dark-400">Статус</span>
|
||||
<span className="text-dark-400">{t('admin.users.detail.status')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={user.status} />
|
||||
{user.status === 'active' ? (
|
||||
@@ -438,7 +450,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-rose-500/20 px-3 py-1 text-xs text-rose-400 transition-colors hover:bg-rose-500/30"
|
||||
>
|
||||
Заблокировать
|
||||
{t('admin.users.actions.block')}
|
||||
</button>
|
||||
) : user.status === 'blocked' ? (
|
||||
<button
|
||||
@@ -446,7 +458,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-emerald-500/20 px-3 py-1 text-xs text-emerald-400 transition-colors hover:bg-emerald-500/30"
|
||||
>
|
||||
Разблокировать
|
||||
{t('admin.users.actions.unblock')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -459,50 +471,68 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
<div className="text-dark-100">{user.email || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Язык</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.language')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.language}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Регистрация</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.registration')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.created_at)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Последняя активность</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.lastActivity')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Всего потрачено</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.totalSpent')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatWithCurrency(user.total_spent_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Покупок</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.purchases')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.purchase_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-200">Реферальная программа</div>
|
||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||
{t('admin.users.detail.referral.title')}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.referrals_count}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">Рефералов</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.referrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">Заработано</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.earned')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.commission_percent || 0}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">Комиссия</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.commission')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -510,16 +540,22 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
{/* Restrictions */}
|
||||
{(user.restriction_topup || user.restriction_subscription) && (
|
||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-rose-400">Ограничения</div>
|
||||
<div className="mb-2 text-sm font-medium text-rose-400">
|
||||
{t('admin.users.detail.restrictions.title')}
|
||||
</div>
|
||||
{user.restriction_topup && (
|
||||
<div className="text-xs text-rose-300">• Запрет пополнения</div>
|
||||
<div className="text-xs text-rose-300">
|
||||
{t('admin.users.detail.restrictions.topup')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_subscription && (
|
||||
<div className="text-xs text-rose-300">• Запрет покупки подписки</div>
|
||||
<div className="text-xs text-rose-300">
|
||||
{t('admin.users.detail.restrictions.subscription')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_reason && (
|
||||
<div className="mt-1 text-xs text-dark-400">
|
||||
Причина: {user.restriction_reason}
|
||||
{t('admin.users.detail.restrictions.reason')}: {user.restriction_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -535,31 +571,42 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
{/* Current subscription */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">Текущая подписка</span>
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.current')}
|
||||
</span>
|
||||
<StatusBadge status={user.subscription.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Тариф</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.tariff')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.tariff_name || 'Не указан'}
|
||||
{user.subscription.tariff_name ||
|
||||
t('admin.users.detail.subscription.notSpecified')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Действует до</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.validUntil')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatDate(user.subscription.end_date)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Трафик</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.traffic')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.traffic_used_gb.toFixed(1)} /{' '}
|
||||
{user.subscription.traffic_limit_gb} ГБ
|
||||
{user.subscription.traffic_limit_gb} {t('common.units.gb')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Устройств</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.devices')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.subscription.device_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -567,17 +614,27 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">Действия</div>
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.actions')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={subAction}
|
||||
onChange={(e) => setSubAction(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="extend">Продлить</option>
|
||||
<option value="change_tariff">Сменить тариф</option>
|
||||
<option value="cancel">Отменить</option>
|
||||
<option value="activate">Активировать</option>
|
||||
<option value="extend">
|
||||
{t('admin.users.detail.subscription.extend')}
|
||||
</option>
|
||||
<option value="change_tariff">
|
||||
{t('admin.users.detail.subscription.changeTariff')}
|
||||
</option>
|
||||
<option value="cancel">
|
||||
{t('admin.users.detail.subscription.cancel')}
|
||||
</option>
|
||||
<option value="activate">
|
||||
{t('admin.users.detail.subscription.activate')}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{subAction === 'extend' && (
|
||||
@@ -585,7 +642,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder="Дней"
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
)}
|
||||
@@ -598,10 +655,14 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">Выберите тариф</option>
|
||||
{tariffs.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} {!t.is_available && '(недоступен)'}
|
||||
<option value="">
|
||||
{t('admin.users.detail.subscription.selectTariff')}
|
||||
</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}{' '}
|
||||
{!tariffItem.is_available &&
|
||||
t('admin.users.detail.subscription.unavailable')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -612,14 +673,18 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-blue-500 py-2 text-white transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Применение...' : 'Применить'}
|
||||
{actionLoading
|
||||
? t('admin.users.actions.applying')
|
||||
: t('admin.users.actions.apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-4 text-center text-dark-400">Нет активной подписки</div>
|
||||
<div className="mb-4 text-center text-dark-400">
|
||||
{t('admin.users.detail.subscription.noActive')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
@@ -628,10 +693,10 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">Выберите тариф</option>
|
||||
{tariffs.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -639,7 +704,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder="Дней"
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<button
|
||||
@@ -650,7 +715,9 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-emerald-500 py-2 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Создание...' : 'Создать подписку'}
|
||||
{actionLoading
|
||||
? t('admin.users.detail.subscription.creating')
|
||||
: t('admin.users.detail.subscription.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -663,7 +730,9 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
<div className="space-y-4">
|
||||
{/* Current balance */}
|
||||
<div className="rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/20 to-purple-500/20 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">Текущий баланс</div>
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.users.detail.balance.current')}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-dark-100">
|
||||
{formatWithCurrency(user.balance_rubles)}
|
||||
</div>
|
||||
@@ -675,14 +744,14 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
type="number"
|
||||
value={balanceAmount}
|
||||
onChange={(e) => setBalanceAmount(e.target.value)}
|
||||
placeholder="Сумма в рублях"
|
||||
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={balanceDescription}
|
||||
onChange={(e) => setBalanceDescription(e.target.value)}
|
||||
placeholder="Описание (опционально)"
|
||||
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
@@ -691,14 +760,14 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-500 py-2 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon /> Начислить
|
||||
<PlusIcon /> {t('admin.users.detail.balance.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(false)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-rose-500 py-2 text-white transition-colors hover:bg-rose-600 disabled:opacity-50"
|
||||
>
|
||||
<MinusIcon /> Списать
|
||||
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -706,7 +775,9 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
{/* Recent transactions */}
|
||||
{user.recent_transactions.length > 0 && (
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">Последние транзакции</div>
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.balance.recentTransactions')}
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{user.recent_transactions.map((tx) => (
|
||||
<div
|
||||
@@ -741,9 +812,13 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{syncStatus.has_differences ? (
|
||||
<span className="font-medium text-amber-400">Есть расхождения</span>
|
||||
<span className="font-medium text-amber-400">
|
||||
{t('admin.users.detail.sync.hasDifferences')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-emerald-400">Синхронизировано</span>
|
||||
<span className="font-medium text-emerald-400">
|
||||
{t('admin.users.detail.sync.synced')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -759,36 +834,48 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">Бот</div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.bot')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Статус:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_status || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">До:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.until')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_end_date
|
||||
? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString(
|
||||
'ru-RU',
|
||||
locale,
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Трафик:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_traffic_used_gb.toFixed(2)} ГБ
|
||||
{syncStatus.bot_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Устройства:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Сквады:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_squads?.length || 0}
|
||||
</span>
|
||||
@@ -796,32 +883,44 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">Панель</div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.panel')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Статус:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_status || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">До:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.until')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_expire_at
|
||||
? new Date(syncStatus.panel_expire_at).toLocaleDateString('ru-RU')
|
||||
? new Date(syncStatus.panel_expire_at).toLocaleDateString(locale)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Трафик:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_traffic_used_gb.toFixed(2)} ГБ
|
||||
{syncStatus.panel_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Устройства:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Сквады:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_squads?.length || 0}
|
||||
</span>
|
||||
@@ -836,7 +935,9 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">Remnawave UUID</div>
|
||||
<div className="break-all font-mono text-sm text-dark-100">
|
||||
{syncStatus?.remnawave_uuid || user.remnawave_uuid || 'Не привязан'}
|
||||
{syncStatus?.remnawave_uuid ||
|
||||
user.remnawave_uuid ||
|
||||
t('admin.users.detail.sync.notLinked')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -848,7 +949,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/20 py-3 text-blue-400 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
Из панели в бота
|
||||
{t('admin.users.detail.sync.fromPanel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncToPanel}
|
||||
@@ -856,7 +957,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-purple-500/30 bg-purple-500/20 py-3 text-purple-400 transition-colors hover:bg-purple-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
Из бота в панель
|
||||
{t('admin.users.detail.sync.toPanel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -870,6 +971,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [users, setUsers] = useState<UserListItem[]>([]);
|
||||
@@ -941,8 +1043,8 @@ export default function AdminUsers() {
|
||||
<ChevronLeftIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">Пользователи</h1>
|
||||
<p className="text-sm text-dark-400">Управление пользователями бота</p>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.users.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -959,15 +1061,27 @@ export default function AdminUsers() {
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatCard title="Всего" value={stats.total_users} color="blue" />
|
||||
<StatCard title="Активных" value={stats.active_users} color="green" />
|
||||
<StatCard title={t('admin.users.stats.total')} value={stats.total_users} color="blue" />
|
||||
<StatCard
|
||||
title="С подпиской"
|
||||
title={t('admin.users.stats.active')}
|
||||
value={stats.active_users}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('admin.users.stats.withSubscription')}
|
||||
value={stats.users_with_active_subscription}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard title="Новых сегодня" value={stats.new_today} color="yellow" />
|
||||
<StatCard title="Заблокировано" value={stats.blocked_users} color="red" />
|
||||
<StatCard
|
||||
title={t('admin.users.stats.newToday')}
|
||||
value={stats.new_today}
|
||||
color="yellow"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('admin.users.stats.blocked')}
|
||||
value={stats.blocked_users}
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -979,7 +1093,7 @@ export default function AdminUsers() {
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по ID, имени, username..."
|
||||
placeholder={t('admin.users.search')}
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-4 text-dark-100 placeholder-dark-500 focus:border-dark-600 focus:outline-none"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
@@ -995,10 +1109,10 @@ export default function AdminUsers() {
|
||||
}}
|
||||
className="rounded-xl border border-dark-700 bg-dark-800 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="blocked">Заблокированные</option>
|
||||
<option value="deleted">Удалённые</option>
|
||||
<option value="">{t('admin.users.filters.allStatuses')}</option>
|
||||
<option value="active">{t('admin.users.status.active')}</option>
|
||||
<option value="blocked">{t('admin.users.status.blocked')}</option>
|
||||
<option value="deleted">{t('admin.users.status.deleted')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={sortBy}
|
||||
@@ -1008,10 +1122,10 @@ export default function AdminUsers() {
|
||||
}}
|
||||
className="rounded-xl border border-dark-700 bg-dark-800 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="created_at">По дате</option>
|
||||
<option value="balance">По балансу</option>
|
||||
<option value="last_activity">По активности</option>
|
||||
<option value="total_spent">По расходам</option>
|
||||
<option value="created_at">{t('admin.users.filters.byDate')}</option>
|
||||
<option value="balance">{t('admin.users.filters.byBalance')}</option>
|
||||
<option value="last_activity">{t('admin.users.filters.byActivity')}</option>
|
||||
<option value="total_spent">{t('admin.users.filters.bySpent')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1022,7 +1136,7 @@ export default function AdminUsers() {
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="py-12 text-center text-dark-400">Пользователи не найдены</div>
|
||||
<div className="py-12 text-center text-dark-400">{t('admin.users.noData')}</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<UserRow
|
||||
@@ -1039,7 +1153,11 @@ export default function AdminUsers() {
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-dark-400">
|
||||
Показано {offset + 1}-{Math.min(offset + limit, total)} из {total}
|
||||
{t('admin.users.pagination.showing', {
|
||||
from: offset + 1,
|
||||
to: Math.min(offset + limit, total),
|
||||
total,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user