diff --git a/src/components/admin/userDetail/BalanceTab.tsx b/src/components/admin/userDetail/BalanceTab.tsx new file mode 100644 index 0000000..af5d49a --- /dev/null +++ b/src/components/admin/userDetail/BalanceTab.tsx @@ -0,0 +1,293 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNotify } from '../../../platform/hooks/useNotify'; +import { useCurrency } from '../../../hooks/useCurrency'; +import { adminUsersApi, type UserDetailResponse } from '../../../api/adminUsers'; +import { promocodesApi } from '../../../api/promocodes'; +import { promoOffersApi } from '../../../api/promoOffers'; +import { createNumberInputHandler, toNumber } from '../../../utils/inputHelpers'; + +// ────────────────────────────────────────────────────────────────── +// Icons — local; balance is the only consumer. +// ────────────────────────────────────────────────────────────────── + +const PlusIcon = () => ( + + + +); + +const MinusIcon = () => ( + + + +); + +// ────────────────────────────────────────────────────────────────── +// Balance tab — current balance, add/subtract form, active promo +// offer summary, send-offer form, recent transactions list. State +// (form inputs + inline-confirm arm) is local; the parent only +// owns the user query and is told when to refresh. +// ────────────────────────────────────────────────────────────────── + +export interface BalanceTabProps { + user: UserDetailResponse; + userId: number; + hasPermission: (perm: string) => boolean; + onUserRefresh: () => Promise | void; + formatDate: (date: string | null) => string; +} + +export function BalanceTab({ + user, + userId, + hasPermission, + onUserRefresh, + formatDate, +}: BalanceTabProps) { + const { t } = useTranslation(); + const notify = useNotify(); + const { formatWithCurrency } = useCurrency(); + + const [balanceAmount, setBalanceAmount] = useState(''); + const [balanceDescription, setBalanceDescription] = useState(''); + const [offerDiscountPercent, setOfferDiscountPercent] = useState(''); + const [offerValidHours, setOfferValidHours] = useState(24); + + const [actionLoading, setActionLoading] = useState(false); + const [offerSending, setOfferSending] = useState(false); + + // Inline two-click confirm — local so other tabs aren't dimmed. + const [confirmingAction, setConfirmingAction] = useState(null); + const handleInlineConfirm = (actionKey: string, executeFn: () => Promise) => { + if (confirmingAction === actionKey) { + setConfirmingAction(null); + executeFn(); + } else { + setConfirmingAction(actionKey); + setTimeout(() => { + setConfirmingAction((current) => (current === actionKey ? null : current)); + }, 3000); + } + }; + + // ─── Mutations ────────────────────────────────────────────────── + + const handleUpdateBalance = async (isAdd: boolean) => { + if (balanceAmount === '') return; + setActionLoading(true); + try { + const amount = Math.abs(toNumber(balanceAmount) * 100); + await adminUsersApi.updateBalance(userId, { + amount_kopeks: isAdd ? amount : -amount, + description: + balanceDescription || + (isAdd + ? t('admin.users.detail.balance.addByAdmin') + : t('admin.users.detail.balance.subtractByAdmin')), + }); + await onUserRefresh(); + setBalanceAmount(''); + setBalanceDescription(''); + } catch (error) { + console.error('Failed to update balance:', error); + } finally { + setActionLoading(false); + } + }; + + const handleDeactivateOffer = async () => { + setActionLoading(true); + try { + await promocodesApi.deactivateDiscount(userId); + notify.success(t('admin.users.detail.offerDeactivated'), t('common.success')); + await onUserRefresh(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleSendOffer = async () => { + if (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 onUserRefresh(); + } catch { + notify.error(t('admin.users.detail.offerSendError'), t('common.error')); + } finally { + setOfferSending(false); + } + }; + + // ─── Render ───────────────────────────────────────────────────── + + return ( +
+ {/* Current balance */} +
+
{t('admin.users.detail.balance.current')}
+
+ {formatWithCurrency(user.balance_rubles)} +
+
+ + {/* Add/subtract form */} + {hasPermission('users:balance') && ( +
+ + setBalanceDescription(e.target.value)} + placeholder={t('admin.users.detail.balance.descriptionPlaceholder')} + className="input" + maxLength={500} + /> +
+ + +
+
+ )} + + {/* Active promo offer */} + {user.promo_offer_discount_percent > 0 && ( +
+
+ + {t('admin.users.detail.activePromoOffer')} + + +
+
+
+
+ {user.promo_offer_discount_percent}% +
+
{t('admin.users.detail.discount')}
+
+
+
+ {user.promo_offer_discount_source || '-'} +
+
{t('admin.users.detail.source')}
+
+
+
+ {user.promo_offer_discount_expires_at + ? formatDate(user.promo_offer_discount_expires_at) + : '-'} +
+
{t('admin.users.detail.expiresAt')}
+
+
+
+ )} + + {/* Send promo offer */} + {hasPermission('users:send_offer') && ( +
+
+ {t('admin.users.detail.sendOffer')} +
+
+ + + +
+
+ )} + + {/* Recent transactions */} + {user.recent_transactions.length > 0 && ( +
+
+ {t('admin.users.detail.balance.recentTransactions')} +
+
+ {user.recent_transactions.map((tx) => ( +
+
+
{tx.description || tx.type}
+
{formatDate(tx.created_at)}
+
+
= 0 ? 'text-success-400' : 'text-error-400'}> + {tx.amount_kopeks >= 0 ? '+' : ''} + {formatWithCurrency(tx.amount_rubles)} +
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 44891fa..61f0e26 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -21,11 +21,11 @@ import { } 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 { AdminBackButton } from '../components/admin'; import { GiftsTab } from '../components/admin/userDetail/GiftsTab'; import { SyncTab } from '../components/admin/userDetail/SyncTab'; import { ReferralsTab } from '../components/admin/userDetail/ReferralsTab'; +import { BalanceTab } from '../components/admin/userDetail/BalanceTab'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import { usePermissionStore } from '../store/permissions'; import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; @@ -132,9 +132,7 @@ export default function AdminUserDetail() { const [confirmingAction, setConfirmingAction] = useState(null); const confirmTimerRef = useRef | null>(null); - // Balance form - const [balanceAmount, setBalanceAmount] = useState(''); - const [balanceDescription, setBalanceDescription] = useState(''); + // (Balance form state moved into BalanceTab.tsx) // Tickets const [tickets, setTickets] = useState([]); @@ -163,10 +161,7 @@ export default function AdminUserDetail() { const [editingReferralCommission, setEditingReferralCommission] = useState(false); const [referralCommissionValue, setReferralCommissionValue] = useState(''); - // Send promo offer - const [offerDiscountPercent, setOfferDiscountPercent] = useState(''); - const [offerValidHours, setOfferValidHours] = useState(24); - const [offerSending, setOfferSending] = useState(false); + // (Send-promo-offer form state moved into BalanceTab.tsx) // Traffic packages const [selectedTrafficGb, setSelectedTrafficGb] = useState(''); @@ -479,28 +474,7 @@ export default function AdminUserDetail() { // All other per-tab data fetching is driven by useQuery `enabled` gating // wired to userId / activeSubscriptionId / activeTab — no manual triggers needed. - const handleUpdateBalance = async (isAdd: boolean) => { - if (balanceAmount === '' || !userId) return; - setActionLoading(true); - try { - const amount = Math.abs(toNumber(balanceAmount) * 100); - await adminUsersApi.updateBalance(userId, { - amount_kopeks: isAdd ? amount : -amount, - description: - balanceDescription || - (isAdd - ? t('admin.users.detail.balance.addByAdmin') - : t('admin.users.detail.balance.subtractByAdmin')), - }); - await loadUser(); - setBalanceAmount(''); - setBalanceDescription(''); - } catch (error) { - console.error('Failed to update balance:', error); - } finally { - setActionLoading(false); - } - }; + // (handleUpdateBalance moved into BalanceTab.tsx) const handleUpdateSubscription = async (overrideAction?: string) => { if (!userId) return; @@ -767,42 +741,7 @@ export default function AdminUserDetail() { } }; - 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); - } - }; + // (handleDeactivateOffer / handleSendOffer moved into BalanceTab.tsx) // (Referrals-tab handlers + debounced search useEffects + click-outside // useEffects moved into components/admin/userDetail/ReferralsTab.tsx) @@ -2483,165 +2422,14 @@ export default function AdminUserDetail() { )} {/* Balance Tab */} - {activeTab === 'balance' && ( -
- {/* Current balance */} -
-
- {t('admin.users.detail.balance.current')} -
-
- {formatWithCurrency(user.balance_rubles)} -
-
- - {/* Add/subtract form */} - {hasPermission('users:balance') && ( -
- - setBalanceDescription(e.target.value)} - placeholder={t('admin.users.detail.balance.descriptionPlaceholder')} - className="input" - maxLength={500} - /> -
- - -
-
- )} - - {/* Active promo offer */} - {user.promo_offer_discount_percent > 0 && ( -
-
- - {t('admin.users.detail.activePromoOffer')} - - -
-
-
-
- {user.promo_offer_discount_percent}% -
-
{t('admin.users.detail.discount')}
-
-
-
- {user.promo_offer_discount_source || '-'} -
-
{t('admin.users.detail.source')}
-
-
-
- {user.promo_offer_discount_expires_at - ? formatDate(user.promo_offer_discount_expires_at) - : '-'} -
-
{t('admin.users.detail.expiresAt')}
-
-
-
- )} - - {/* Send promo offer */} - {hasPermission('users:send_offer') && ( -
-
- {t('admin.users.detail.sendOffer')} -
-
- - - -
-
- )} - - {/* Recent transactions */} - {user.recent_transactions.length > 0 && ( -
-
- {t('admin.users.detail.balance.recentTransactions')} -
-
- {user.recent_transactions.map((tx) => ( -
-
-
{tx.description || tx.type}
-
{formatDate(tx.created_at)}
-
-
= 0 ? 'text-success-400' : 'text-error-400'} - > - {tx.amount_kopeks >= 0 ? '+' : ''} - {formatWithCurrency(tx.amount_rubles)} -
-
- ))} -
-
- )} -
+ {activeTab === 'balance' && userId && ( + )} {/* Sync Tab */}