Merge pull request #181 from BEDOLAGA-DEV/feat/admin-user-promo-management

feat: promo group & offer management in AdminUserDetail
This commit is contained in:
Egor
2026-02-07 08:15:55 +03:00
committed by GitHub
6 changed files with 295 additions and 173 deletions

View File

@@ -183,4 +183,12 @@ export const promocodesApi = {
deletePromoGroup: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`);
},
// Deactivate user's active discount (admin)
deactivateDiscount: async (userId: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(
`/cabinet/admin/promocodes/deactivate-discount/${userId}`,
);
return response.data;
},
};

View File

@@ -1886,6 +1886,22 @@
"title": "Actions",
"areYouSure": "Are you sure?"
},
"promoGroup": "Promo group",
"noPromoGroup": "Not set",
"changePromoGroup": "Change",
"selectPromoGroup": "Select group",
"removePromoGroup": "Remove",
"activePromoOffer": "Active promo offer",
"discount": "Discount",
"source": "Source",
"expiresAt": "Expires",
"deactivateOffer": "Deactivate",
"sendOffer": "Send offer",
"discountPercent": "Discount %",
"validHours": "Valid hours",
"offerSent": "Offer sent",
"offerSendError": "Failed to send offer",
"offerDeactivated": "Offer deactivated",
"subscription": {
"current": "Current subscription",
"tariff": "Tariff",

View File

@@ -1585,6 +1585,22 @@
"subscription": "• خرید اشتراک ممنوع",
"reason": "دلیل"
},
"promoGroup": "گروه تبلیغاتی",
"noPromoGroup": "تنظیم نشده",
"changePromoGroup": "تغییر",
"selectPromoGroup": "انتخاب گروه",
"removePromoGroup": "حذف",
"activePromoOffer": "پیشنهاد فعال",
"discount": "تخفیف",
"source": "منبع",
"expiresAt": "انقضا",
"deactivateOffer": "غیرفعال کردن",
"sendOffer": "ارسال پیشنهاد",
"discountPercent": "تخفیف %",
"validHours": "مدت اعتبار (ساعت)",
"offerSent": "پیشنهاد ارسال شد",
"offerSendError": "خطا در ارسال پیشنهاد",
"offerDeactivated": "پیشنهاد غیرفعال شد",
"subscription": {
"current": "اشتراک فعلی",
"tariff": "تعرفه",

View File

@@ -2408,6 +2408,22 @@
"subscription": "• Запрет покупки подписки",
"reason": "Причина"
},
"promoGroup": "Промо-группа",
"noPromoGroup": "Не задана",
"changePromoGroup": "Изменить",
"selectPromoGroup": "Выберите группу",
"removePromoGroup": "Убрать",
"activePromoOffer": "Активное промо-предложение",
"discount": "Скидка",
"source": "Источник",
"expiresAt": "Истекает",
"deactivateOffer": "Отключить",
"sendOffer": "Отправить предложение",
"discountPercent": "Скидка %",
"validHours": "Срок действия (часы)",
"offerSent": "Предложение отправлено",
"offerSendError": "Ошибка отправки предложения",
"offerDeactivated": "Предложение отключено",
"actions": {
"title": "Действия",
"areYouSure": "Уверены?"
@@ -2957,164 +2973,6 @@
"uptime": "Аптайм"
}
},
"adminUsers": {
"title": "Пользователи",
"subtitle": "Управление пользователями",
"searchPlaceholder": "Поиск по ID, username, имени...",
"filters": {
"all": "Все",
"active": "Активные",
"blocked": "Заблокированные",
"deleted": "Удалённые"
},
"sort": {
"created_at": "Дата регистрации",
"balance": "Баланс",
"traffic": "Трафик",
"last_activity": "Активность",
"total_spent": "Потрачено",
"purchase_count": "Покупки"
},
"stats": {
"totalUsers": "Всего пользователей",
"activeUsers": "Активных",
"blockedUsers": "Заблокировано",
"withSubscription": "С подпиской",
"newToday": "Новых сегодня",
"newWeek": "За неделю",
"newMonth": "За месяц",
"totalBalance": "Общий баланс",
"activeToday": "Активны сегодня",
"activeWeek": "За неделю",
"activeMonth": "За месяц"
},
"table": {
"user": "Пользователь",
"status": "Статус",
"balance": "Баланс",
"subscription": "Подписка",
"spent": "Потрачено",
"registered": "Регистрация",
"activity": "Активность",
"noUsers": "Пользователи не найдены"
},
"userStatus": {
"active": "Активен",
"blocked": "Заблокирован",
"deleted": "Удалён"
},
"subscriptionStatus": {
"active": "Активна",
"trial": "Триал",
"expired": "Истекла",
"none": "Нет подписки"
},
"detail": {
"title": "Информация о пользователе",
"tabs": {
"info": "Инфо",
"subscription": "Подписка",
"balance": "Баланс",
"sync": "Синхронизация"
},
"telegramId": "Telegram ID",
"username": "Username",
"fullName": "Имя",
"email": "Email",
"emailVerified": "Подтверждён",
"emailNotVerified": "Не подтверждён",
"language": "Язык",
"registeredAt": "Регистрация",
"lastActivity": "Последняя активность",
"cabinetLogin": "Вход в кабинет",
"referralCode": "Реферальный код",
"referralsCount": "Рефералов",
"referralEarnings": "Заработано",
"referredBy": "Приглашён",
"promoGroup": "Промо-группа",
"noPromoGroup": "Не задана",
"restrictions": "Ограничения",
"restrictionTopup": "Пополнение баланса",
"restrictionSubscription": "Покупка подписки",
"restrictionReason": "Причина",
"noRestrictions": "Нет ограничений",
"totalSpent": "Всего потрачено",
"purchaseCount": "Покупок",
"usedPromocodes": "Использовано промокодов",
"lifetimeTraffic": "Трафик за всё время",
"recentTransactions": "Последние транзакции",
"noTransactions": "Нет транзакций"
},
"subscription": {
"noSubscription": "Подписка отсутствует",
"currentTariff": "Текущий тариф",
"status": "Статус",
"endDate": "Окончание",
"daysRemaining": "Осталось дней",
"trafficUsed": "Использовано трафика",
"deviceLimit": "Лимит устройств",
"autopay": "Автоплатёж",
"enabled": "Включён",
"disabled": "Отключён",
"actions": {
"extend": "Продлить",
"changeTariff": "Сменить тариф",
"setTraffic": "Установить трафик",
"toggleAutopay": "Вкл/выкл автоплатёж",
"cancel": "Отменить подписку",
"create": "Создать подписку"
},
"extendDays": "Дней",
"selectTariff": "Выберите тариф",
"trafficLimitGb": "Лимит ГБ",
"trafficUsedGb": "Использовано ГБ",
"isTrial": "Пробный период"
},
"balance": {
"currentBalance": "Текущий баланс",
"addFunds": "Пополнить",
"deductFunds": "Списать",
"amount": "Сумма",
"description": "Описание",
"createTransaction": "Создать транзакцию",
"transactionHistory": "История транзакций"
},
"sync": {
"title": "Синхронизация с панелью",
"status": "Статус синхронизации",
"lastSync": "Последняя синхронизация",
"neverSynced": "Никогда",
"botData": "Данные бота",
"panelData": "Данные панели",
"notFound": "Не найден в панели",
"differences": "Различия",
"noDifferences": "Различий нет",
"syncFromPanel": "Синхронизировать из панели",
"syncToPanel": "Синхронизировать в панель",
"remawave_uuid": "Remnawave UUID",
"notLinked": "Не привязан"
},
"actions": {
"block": "Заблокировать",
"unblock": "Разблокировать",
"delete": "Удалить",
"blockReason": "Причина блокировки",
"confirmBlock": "Подтвердить блокировку",
"confirmDelete": "Подтвердить удаление"
},
"messages": {
"loadError": "Не удалось загрузить пользователей",
"userLoadError": "Не удалось загрузить пользователя",
"balanceUpdated": "Баланс обновлён",
"balanceError": "Ошибка обновления баланса",
"subscriptionUpdated": "Подписка обновлена",
"subscriptionError": "Ошибка обновления подписки",
"statusUpdated": "Статус обновлён",
"statusError": "Ошибка обновления статуса",
"syncSuccess": "Синхронизация выполнена",
"syncError": "Ошибка синхронизации"
}
},
"profile": {
"title": "Профиль",
"accountInfo": "Информация об аккаунте",

View File

@@ -1584,6 +1584,22 @@
"subscription": "• 禁止购买订阅",
"reason": "原因"
},
"promoGroup": "优惠组",
"noPromoGroup": "未设置",
"changePromoGroup": "更改",
"selectPromoGroup": "选择组",
"removePromoGroup": "移除",
"activePromoOffer": "活跃促销优惠",
"discount": "折扣",
"source": "来源",
"expiresAt": "到期时间",
"deactivateOffer": "停用",
"sendOffer": "发送优惠",
"discountPercent": "折扣 %",
"validHours": "有效期(小时)",
"offerSent": "优惠已发送",
"offerSendError": "发送优惠失败",
"offerDeactivated": "优惠已停用",
"subscription": {
"current": "当前订阅",
"tariff": "套餐",

View File

@@ -15,6 +15,8 @@ import {
type UpdateSubscriptionRequest,
} from '../api/adminUsers';
import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin';
import { promocodesApi, type PromoGroup } from '../api/promocodes';
import { promoOffersApi } from '../api/promoOffers';
import { ticketsApi } from '../api/tickets';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
@@ -180,6 +182,15 @@ export default function AdminUserDetail() {
const [subDays, setSubDays] = useState<number | ''>(30);
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
// Promo group
const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]);
const [editingPromoGroup, setEditingPromoGroup] = useState(false);
// Send promo offer
const [offerDiscountPercent, setOfferDiscountPercent] = useState<number | ''>('');
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
const [offerSending, setOfferSending] = useState(false);
const userId = id ? parseInt(id, 10) : null;
const loadUser = useCallback(async () => {
@@ -282,6 +293,15 @@ export default function AdminUserDetail() {
await Promise.all([loadPanelInfo(), loadNodeUsage()]);
}, [loadPanelInfo, loadNodeUsage]);
const loadPromoGroups = useCallback(async () => {
try {
const data = await promocodesApi.getPromoGroups({ limit: 100 });
setPromoGroups(data.items);
} catch {
// ignore
}
}, []);
const handleTicketReply = async () => {
if (!selectedTicketId || !replyText.trim()) return;
setReplySending(true);
@@ -332,14 +352,25 @@ export default function AdminUserDetail() {
}, [userId, loadUser, navigate]);
useEffect(() => {
if (activeTab === 'info') loadReferrals();
if (activeTab === 'info') {
loadReferrals();
loadPromoGroups();
}
if (activeTab === 'sync') loadSyncStatus();
if (activeTab === 'subscription') {
loadTariffs();
loadSubscriptionData();
}
if (activeTab === 'tickets') loadTickets();
}, [activeTab, loadSyncStatus, loadTariffs, loadTickets, loadReferrals, loadSubscriptionData]);
}, [
activeTab,
loadSyncStatus,
loadTariffs,
loadTickets,
loadReferrals,
loadSubscriptionData,
loadPromoGroups,
]);
const handleUpdateBalance = async (isAdd: boolean) => {
if (balanceAmount === '' || !userId) return;
@@ -364,17 +395,16 @@ export default function AdminUserDetail() {
}
};
const handleUpdateSubscription = async () => {
const handleUpdateSubscription = async (overrideAction?: string) => {
if (!userId) return;
setActionLoading(true);
try {
const action = overrideAction || subAction;
const data: UpdateSubscriptionRequest = {
action: subAction as UpdateSubscriptionRequest['action'],
...(subAction === 'extend' ? { days: toNumber(subDays, 30) } : {}),
...(subAction === 'change_tariff' && selectedTariffId
? { tariff_id: selectedTariffId }
: {}),
...(subAction === 'create'
action: action as UpdateSubscriptionRequest['action'],
...(action === 'extend' ? { days: toNumber(subDays, 30) } : {}),
...(action === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}),
...(action === 'create'
? {
days: toNumber(subDays, 30),
...(selectedTariffId ? { tariff_id: selectedTariffId } : {}),
@@ -451,7 +481,7 @@ export default function AdminUserDetail() {
if (confirmingAction === actionKey) {
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
setConfirmingAction(null);
executeFn();
executeFn().catch(() => {});
} else {
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
setConfirmingAction(actionKey);
@@ -459,6 +489,57 @@ export default function AdminUserDetail() {
}
};
const handleChangePromoGroup = async (groupId: number | null) => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.updatePromoGroup(userId, groupId);
await loadUser();
setEditingPromoGroup(false);
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleDeactivateOffer = async () => {
if (!userId) return;
setActionLoading(true);
try {
await promocodesApi.deactivateDiscount(userId);
notify.success(t('admin.users.detail.offerDeactivated'), t('common.success'));
await loadUser();
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleSendOffer = async () => {
if (!userId || offerDiscountPercent === '' || offerValidHours === '') return;
setOfferSending(true);
try {
await promoOffersApi.broadcastOffer({
user_id: userId,
notification_type: 'admin_personal',
discount_percent: toNumber(offerDiscountPercent),
valid_hours: toNumber(offerValidHours, 24),
effect_type: 'percent_discount',
send_notification: true,
});
notify.success(t('admin.users.detail.offerSent'), t('common.success'));
setOfferDiscountPercent('');
setOfferValidHours(24);
await loadUser();
} catch {
notify.error(t('admin.users.detail.offerSendError'), t('common.error'));
} finally {
setOfferSending(false);
}
};
const handleResetTrial = async () => {
if (!userId) return;
setActionLoading(true);
@@ -717,6 +798,56 @@ export default function AdminUserDetail() {
</div>
)}
{/* Promo Group */}
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-dark-500">{t('admin.users.detail.promoGroup')}</span>
<button
onClick={() => setEditingPromoGroup(!editingPromoGroup)}
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
>
{editingPromoGroup
? t('common.cancel')
: t('admin.users.detail.changePromoGroup')}
</button>
</div>
{editingPromoGroup ? (
<div className="mt-2 space-y-2">
<select
value={user.promo_group?.id ?? ''}
onChange={(e) => {
const val = e.target.value;
handleChangePromoGroup(val ? parseInt(val, 10) : null);
}}
disabled={actionLoading}
className="input text-sm"
>
<option value="">{t('admin.users.detail.selectPromoGroup')}</option>
{promoGroups.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
{user.promo_group && (
<button
onClick={() => handleChangePromoGroup(null)}
disabled={actionLoading}
className="w-full rounded-lg bg-dark-700 py-1.5 text-xs text-dark-300 transition-colors hover:bg-dark-600"
>
{t('admin.users.detail.removePromoGroup')}
</button>
)}
</div>
) : (
<div className="text-sm font-medium text-dark-100">
{user.promo_group?.name || (
<span className="text-dark-500">{t('admin.users.detail.noPromoGroup')}</span>
)}
</div>
)}
</div>
{/* Referral */}
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-2 text-sm font-medium text-dark-200">
@@ -982,7 +1113,7 @@ export default function AdminUserDetail() {
)}
<button
onClick={handleUpdateSubscription}
onClick={() => handleUpdateSubscription()}
disabled={actionLoading}
className="btn-primary w-full"
>
@@ -1023,10 +1154,7 @@ export default function AdminUserDetail() {
max={3650}
/>
<button
onClick={() => {
setSubAction('create');
handleUpdateSubscription();
}}
onClick={() => handleUpdateSubscription('create')}
disabled={actionLoading}
className="btn-primary w-full"
>
@@ -1314,6 +1442,86 @@ export default function AdminUserDetail() {
</div>
</div>
{/* Active promo offer */}
{user.promo_offer_discount_percent > 0 && (
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-accent-400">
{t('admin.users.detail.activePromoOffer')}
</span>
<button
onClick={() => handleInlineConfirm('deactivateOffer', handleDeactivateOffer)}
disabled={actionLoading}
className={`rounded-lg px-3 py-1 text-xs font-medium transition-all disabled:opacity-50 ${
confirmingAction === 'deactivateOffer'
? 'bg-error-500 text-white'
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
}`}
>
{confirmingAction === 'deactivateOffer'
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.detail.deactivateOffer')}
</button>
</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="text-lg font-bold text-dark-100">
{user.promo_offer_discount_percent}%
</div>
<div className="text-xs text-dark-500">{t('admin.users.detail.discount')}</div>
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user.promo_offer_discount_source || '-'}
</div>
<div className="text-xs text-dark-500">{t('admin.users.detail.source')}</div>
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user.promo_offer_discount_expires_at
? formatDate(user.promo_offer_discount_expires_at)
: '-'}
</div>
<div className="text-xs text-dark-500">{t('admin.users.detail.expiresAt')}</div>
</div>
</div>
</div>
)}
{/* Send promo offer */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.sendOffer')}
</div>
<div className="space-y-3">
<input
type="number"
value={offerDiscountPercent}
onChange={createNumberInputHandler(setOfferDiscountPercent, 1)}
placeholder={t('admin.users.detail.discountPercent')}
className="input"
min={1}
max={100}
/>
<input
type="number"
value={offerValidHours}
onChange={createNumberInputHandler(setOfferValidHours, 1)}
placeholder={t('admin.users.detail.validHours')}
className="input"
min={1}
max={8760}
/>
<button
onClick={handleSendOffer}
disabled={offerSending || offerDiscountPercent === '' || offerValidHours === ''}
className="btn-primary w-full disabled:opacity-50"
>
{offerSending ? t('common.loading') : t('admin.users.detail.sendOffer')}
</button>
</div>
</div>
{/* Recent transactions */}
{user.recent_transactions.length > 0 && (
<div className="rounded-xl bg-dark-800/50 p-4">