fix(admin-wheel): unify statistics rendering + fill config UI gaps

Statistics tab now uses the shared StatCard + BreakdownList (ranked bars +
share %) and the currency-aware formatter — matching the rest of the admin
stats instead of ad-hoc 'card p-4 text-center' / text-3xl tiles — and gains
proper loading, error and empty states.

Also surface backend-supported config the form couldn't reach (forcing DB edits):
- promo_validity_days (model + schema-bounded 1-365) is now editable in Settings.
- per-prize manual_probability override (0-1) now has an input in the prize form.
This commit is contained in:
c0mrade
2026-06-04 22:33:12 +03:00
parent 73ee42a0ef
commit 5de43c1071
5 changed files with 167 additions and 80 deletions

View File

@@ -1756,7 +1756,8 @@
"dailyLimit": "Daily Spin Limit (0 = unlimited)", "dailyLimit": "Daily Spin Limit (0 = unlimited)",
"minSubDays": "Min subscription days for day payment", "minSubDays": "Min subscription days for day payment",
"promocodes": "Promocodes", "promocodes": "Promocodes",
"promoPrefix": "Promo code prefix" "promoPrefix": "Promo code prefix",
"promoValidityDays": "Promo code validity (days)"
}, },
"starsNotEnabledGlobally": "Star payments are not enabled in Payment Methods. Enable \"Telegram Stars\" in the Payment Methods section.", "starsNotEnabledGlobally": "Star payments are not enabled in Payment Methods. Enable \"Telegram Stars\" in the Payment Methods section.",
"prizes": { "prizes": {
@@ -1783,7 +1784,9 @@
"emoji": "Emoji", "emoji": "Emoji",
"color": "Color", "color": "Color",
"active": "Active", "active": "Active",
"worth": "Worth" "worth": "Worth",
"probability": "Manual probability (01)",
"probabilityHint": "Empty = auto by RTP"
}, },
"promo": { "promo": {
"title": "Promo Code Settings", "title": "Promo Code Settings",
@@ -1801,7 +1804,9 @@
"times": "times", "times": "times",
"times_one": "{{count}} time", "times_one": "{{count}} time",
"times_other": "{{count}} times", "times_other": "{{count}} times",
"topWins": "Top Wins" "topWins": "Top Wins",
"loadError": "Failed to load statistics",
"empty": "No spins yet"
} }
}, },
"broadcasts": { "broadcasts": {

View File

@@ -1432,7 +1432,8 @@
"promoPrefix": "پیشوند کد تخفیف", "promoPrefix": "پیشوند کد تخفیف",
"spinCost": "هزینه چرخش", "spinCost": "هزینه چرخش",
"limitsAndRtp": "محدودیت‌ها و RTP", "limitsAndRtp": "محدودیت‌ها و RTP",
"promocodes": "کدهای تخفیف" "promocodes": "کدهای تخفیف",
"promoValidityDays": "اعتبار کد تخفیف (روز)"
}, },
"starsNotEnabledGlobally": "پرداخت با ستاره در روش‌های پرداخت فعال نیست. «Telegram Stars» را در بخش «روش‌های پرداخت» فعال کنید.", "starsNotEnabledGlobally": "پرداخت با ستاره در روش‌های پرداخت فعال نیست. «Telegram Stars» را در بخش «روش‌های پرداخت» فعال کنید.",
"prizes": { "prizes": {
@@ -1459,7 +1460,9 @@
"emoji": "ایموجی", "emoji": "ایموجی",
"color": "رنگ", "color": "رنگ",
"active": "فعال", "active": "فعال",
"worth": "ارزش" "worth": "ارزش",
"probability": "احتمال دستی (۰۱)",
"probabilityHint": "خالی = خودکار بر اساس RTP"
}, },
"promo": { "promo": {
"title": "تنظیمات کد تخفیف", "title": "تنظیمات کد تخفیف",
@@ -1476,7 +1479,9 @@
"prizeDistribution": "توزیع جوایز", "prizeDistribution": "توزیع جوایز",
"times": "بار", "times": "بار",
"times_other": "{{count}} بار", "times_other": "{{count}} بار",
"topWins": "برترین برنده‌ها" "topWins": "برترین برنده‌ها",
"loadError": "بارگذاری آمار ناموفق بود",
"empty": "هنوز چرخشی انجام نشده"
} }
}, },
"broadcasts": { "broadcasts": {

View File

@@ -1782,7 +1782,8 @@
"dailyLimit": "Дневной лимит вращений (0 = безлимит)", "dailyLimit": "Дневной лимит вращений (0 = безлимит)",
"minSubDays": "Мин. дней подписки для оплаты днями", "minSubDays": "Мин. дней подписки для оплаты днями",
"promocodes": "Промокоды", "promocodes": "Промокоды",
"promoPrefix": "Префикс промокодов" "promoPrefix": "Префикс промокодов",
"promoValidityDays": "Срок действия промокода (дней)"
}, },
"starsNotEnabledGlobally": "Оплата звёздами не включена в платёжных методах. Включите метод «Telegram Stars» в разделе «Платёжные методы».", "starsNotEnabledGlobally": "Оплата звёздами не включена в платёжных методах. Включите метод «Telegram Stars» в разделе «Платёжные методы».",
"prizes": { "prizes": {
@@ -1809,7 +1810,9 @@
"emoji": "Эмодзи", "emoji": "Эмодзи",
"color": "Цвет", "color": "Цвет",
"active": "Активен", "active": "Активен",
"worth": "Стоимость" "worth": "Стоимость",
"probability": "Ручная вероятность (01)",
"probabilityHint": "Пусто — авто по RTP"
}, },
"promo": { "promo": {
"title": "Настройки промокода", "title": "Настройки промокода",
@@ -1828,7 +1831,9 @@
"times_one": "{{count}} раз", "times_one": "{{count}} раз",
"times_few": "{{count}} раза", "times_few": "{{count}} раза",
"times_many": "{{count}} раз", "times_many": "{{count}} раз",
"topWins": "Топ выигрышей" "topWins": "Топ выигрышей",
"loadError": "Не удалось загрузить статистику",
"empty": "Пока нет вращений"
} }
}, },
"broadcasts": { "broadcasts": {

View File

@@ -1509,7 +1509,8 @@
"promoPrefix": "优惠码前缀", "promoPrefix": "优惠码前缀",
"spinCost": "旋转费用", "spinCost": "旋转费用",
"limitsAndRtp": "限制和RTP", "limitsAndRtp": "限制和RTP",
"promocodes": "促销码" "promocodes": "促销码",
"promoValidityDays": "促销码有效期(天)"
}, },
"starsNotEnabledGlobally": "星星支付未在支付方式中启用。请在「支付方式」中启用「Telegram Stars」。", "starsNotEnabledGlobally": "星星支付未在支付方式中启用。请在「支付方式」中启用「Telegram Stars」。",
"prizes": { "prizes": {
@@ -1536,7 +1537,9 @@
"emoji": "表情", "emoji": "表情",
"color": "颜色", "color": "颜色",
"active": "启用", "active": "启用",
"worth": "价值" "worth": "价值",
"probability": "手动概率 (01)",
"probabilityHint": "留空 = 按 RTP 自动"
}, },
"promo": { "promo": {
"title": "优惠码设置", "title": "优惠码设置",
@@ -1553,7 +1556,9 @@
"prizeDistribution": "奖品分布", "prizeDistribution": "奖品分布",
"times": "次", "times": "次",
"times_other": "{{count}} 次", "times_other": "{{count}} 次",
"topWins": "最高奖品" "topWins": "最高奖品",
"loadError": "无法加载统计数据",
"empty": "暂无抽奖记录"
} }
}, },
"broadcasts": { "broadcasts": {

View File

@@ -40,8 +40,13 @@ import {
StarIcon, StarIcon,
TicketIcon, TicketIcon,
TrashIcon, TrashIcon,
WalletIcon,
WheelIcon,
XMarkIcon, XMarkIcon,
} from '@/components/icons'; } from '@/components/icons';
import { StatCard } from '@/components/stats';
import { BreakdownList } from '@/components/sales-stats/BreakdownList';
import { useCurrency } from '@/hooks/useCurrency';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { toNumber } from '../utils/inputHelpers'; import { toNumber } from '../utils/inputHelpers';
@@ -166,6 +171,7 @@ export default function AdminWheel() {
const confirmDelete = useDestructiveConfirm(); const confirmDelete = useDestructiveConfirm();
const { capabilities } = usePlatform(); const { capabilities } = usePlatform();
const notify = useNotify(); const notify = useNotify();
const { formatWithCurrency } = useCurrency();
const [activeTab, setActiveTab] = useState<Tab>('settings'); const [activeTab, setActiveTab] = useState<Tab>('settings');
const [expandedPrizeId, setExpandedPrizeId] = useState<number | null>(null); const [expandedPrizeId, setExpandedPrizeId] = useState<number | null>(null);
@@ -184,6 +190,7 @@ export default function AdminWheel() {
daily_spin_limit: number | ''; daily_spin_limit: number | '';
min_subscription_days_for_day_payment: number | ''; min_subscription_days_for_day_payment: number | '';
promo_prefix: string; promo_prefix: string;
promo_validity_days: number | '';
} | null>(null); } | null>(null);
// Fetch config // Fetch config
@@ -193,7 +200,11 @@ export default function AdminWheel() {
}); });
// Fetch statistics // Fetch statistics
const { data: stats } = useQuery({ const {
data: stats,
isLoading: statsLoading,
error: statsError,
} = useQuery({
queryKey: ['admin-wheel-stats'], queryKey: ['admin-wheel-stats'],
queryFn: () => adminWheelApi.getStatistics(), queryFn: () => adminWheelApi.getStatistics(),
enabled: activeTab === 'statistics', enabled: activeTab === 'statistics',
@@ -214,6 +225,7 @@ export default function AdminWheel() {
daily_spin_limit: config.daily_spin_limit, daily_spin_limit: config.daily_spin_limit,
min_subscription_days_for_day_payment: config.min_subscription_days_for_day_payment, min_subscription_days_for_day_payment: config.min_subscription_days_for_day_payment,
promo_prefix: config.promo_prefix, promo_prefix: config.promo_prefix,
promo_validity_days: config.promo_validity_days,
}; };
}); });
} }
@@ -232,7 +244,8 @@ export default function AdminWheel() {
settingsForm.daily_spin_limit !== config.daily_spin_limit || settingsForm.daily_spin_limit !== config.daily_spin_limit ||
settingsForm.min_subscription_days_for_day_payment !== settingsForm.min_subscription_days_for_day_payment !==
config.min_subscription_days_for_day_payment || config.min_subscription_days_for_day_payment ||
settingsForm.promo_prefix !== config.promo_prefix); settingsForm.promo_prefix !== config.promo_prefix ||
settingsForm.promo_validity_days !== config.promo_validity_days);
// Update config mutation // Update config mutation
const updateConfigMutation = useMutation({ const updateConfigMutation = useMutation({
@@ -669,6 +682,26 @@ export default function AdminWheel() {
className="input w-full" className="input w-full"
/> />
</div> </div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.settings.promoValidityDays')}
</label>
<input
type="number"
value={settingsForm?.promo_validity_days ?? config.promo_validity_days}
onChange={(e) => {
const val = e.target.value;
setSettingsForm((prev) =>
prev
? { ...prev, promo_validity_days: val === '' ? '' : parseInt(val) || 0 }
: null,
);
}}
min={1}
max={365}
className="input w-full"
/>
</div>
</div> </div>
</div> </div>
@@ -702,6 +735,10 @@ export default function AdminWheel() {
settingsForm.min_subscription_days_for_day_payment, settingsForm.min_subscription_days_for_day_payment,
config.min_subscription_days_for_day_payment, config.min_subscription_days_for_day_payment,
), ),
promo_validity_days: toNumber(
settingsForm.promo_validity_days,
config.promo_validity_days,
),
}); });
}} }}
disabled={updateConfigMutation.isPending} disabled={updateConfigMutation.isPending}
@@ -833,79 +870,82 @@ export default function AdminWheel() {
)} )}
{/* Statistics Tab */} {/* Statistics Tab */}
{activeTab === 'statistics' && stats && ( {activeTab === 'statistics' && statsLoading && (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
)}
{activeTab === 'statistics' && !statsLoading && statsError && (
<div className="bento-card py-8 text-center text-error-400">
{t('admin.wheel.statistics.loadError')}
</div>
)}
{activeTab === 'statistics' &&
!statsLoading &&
!statsError &&
stats &&
stats.total_spins === 0 && (
<div className="bento-card py-12 text-center text-dark-400">
{t('admin.wheel.statistics.empty')}
</div>
)}
{activeTab === 'statistics' && stats && stats.total_spins > 0 && (
<div className="space-y-4"> <div className="space-y-4">
{/* Stats cards */} {/* Headline metrics — shared StatCard, like the rest of the admin stats */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="card p-4 text-center"> <StatCard
<div className="text-3xl font-bold text-accent-400">{stats.total_spins}</div> label={t('admin.wheel.statistics.totalSpins')}
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.totalSpins')}</div> value={stats.total_spins}
</div> icon={<WheelIcon className="h-5 w-5" />}
<div className="card p-4 text-center"> tone="accent"
<div className="text-3xl font-bold text-success-400"> />
{(stats.total_revenue_kopeks / 100).toFixed(0)} <StatCard
</div> label={t('admin.wheel.statistics.revenue')}
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.revenue')}</div> value={formatWithCurrency(stats.total_revenue_kopeks / 100, 0)}
</div> icon={<WalletIcon className="h-5 w-5" />}
<div className="card p-4 text-center"> tone="success"
<div className="text-3xl font-bold text-warning-400"> />
{(stats.total_payout_kopeks / 100).toFixed(0)} <StatCard
</div> label={t('admin.wheel.statistics.payouts')}
<div className="text-sm text-dark-400">{t('admin.wheel.statistics.payouts')}</div> value={formatWithCurrency(stats.total_payout_kopeks / 100, 0)}
</div> icon={<GiftIcon className="h-5 w-5" />}
<div className="card p-4 text-center"> tone="warning"
<div />
className={`text-3xl font-bold ${ <StatCard
stats.actual_rtp_percent <= stats.configured_rtp_percent label={`${t('admin.wheel.statistics.actualRtp')} · ${t('admin.wheel.statistics.targetRtp')} ${stats.configured_rtp_percent}%`}
? 'text-success-400' value={`${stats.actual_rtp_percent.toFixed(1)}%`}
: 'text-error-400' icon={<ChartIcon className="h-5 w-5" />}
}`} tone={stats.actual_rtp_percent <= stats.configured_rtp_percent ? 'success' : 'error'}
> />
{stats.actual_rtp_percent.toFixed(1)}%
</div>
<div className="text-sm text-dark-400">
{t('admin.wheel.statistics.actualRtp')} ({t('admin.wheel.statistics.targetRtp')}:{' '}
{stats.configured_rtp_percent}%)
</div>
</div>
</div> </div>
{/* Prize distribution */} {/* Prize distribution — shared BreakdownList (ranked bars + share %) */}
{stats.prizes_distribution.length > 0 && ( {stats.prizes_distribution.length > 0 && (
<div className="card p-4"> <BreakdownList
<h3 className="mb-3 font-semibold text-dark-100"> title={t('admin.wheel.statistics.prizeDistribution')}
{t('admin.wheel.statistics.prizeDistribution')} items={stats.prizes_distribution.map((prize) => ({
</h3> key: prize.display_name,
<div className="space-y-2"> label: prize.display_name,
{stats.prizes_distribution.map((prize, i) => ( value: prize.count,
<div key={i} className="flex items-center justify-between"> }))}
<span className="text-dark-300">{prize.display_name}</span> valueFormatter={(v) => t('admin.wheel.statistics.times', { count: v })}
<span className="text-dark-100"> />
{t('admin.wheel.statistics.times', { count: prize.count })}
</span>
</div>
))}
</div>
</div>
)} )}
{/* Top wins */} {/* Top wins — same BreakdownList, ranked by win value */}
{stats.top_wins.length > 0 && ( {stats.top_wins.length > 0 && (
<div className="card p-4"> <BreakdownList
<h3 className="mb-3 font-semibold text-dark-100"> title={t('admin.wheel.statistics.topWins')}
{t('admin.wheel.statistics.topWins')} items={stats.top_wins.slice(0, 8).map((win, i) => ({
</h3> key: `${win.user_id}-${i}`,
<div className="space-y-2"> label: `${win.username || `#${win.user_id}`} · ${win.prize_display_name}`,
{stats.top_wins.slice(0, 5).map((win, i) => ( value: win.prize_value_kopeks / 100,
<div key={i} className="flex items-center justify-between"> }))}
<span className="text-dark-300">{win.username || `User #${win.user_id}`}</span> valueFormatter={(v) => formatWithCurrency(v, 0)}
<span className="text-dark-100"> />
{win.prize_display_name} ({(win.prize_value_kopeks / 100).toFixed(0)})
</span>
</div>
))}
</div>
</div>
)} )}
</div> </div>
)} )}
@@ -1097,6 +1137,33 @@ function InlinePrizeForm({
</label> </label>
</div> </div>
{/* Manual probability override (optional; empty = auto by RTP) */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.wheel.prizes.fields.probability')}
</label>
<input
type="number"
value={formData.manual_probability ?? ''}
onChange={(e) => {
const val = e.target.value;
setFormData({
...formData,
manual_probability:
val === '' ? null : Math.min(1, Math.max(0, parseFloat(val) || 0)),
});
}}
min={0}
max={1}
step={0.01}
placeholder="0.00 1.00"
className="input w-full sm:w-1/2"
/>
<p className="mt-1 text-xs text-dark-500">
{t('admin.wheel.prizes.fields.probabilityHint')}
</p>
</div>
{/* Promocode settings */} {/* Promocode settings */}
{formData.prize_type === 'promocode' && ( {formData.prize_type === 'promocode' && (
<div className="space-y-3 rounded-lg bg-dark-700/50 p-3"> <div className="space-y-3 rounded-lg bg-dark-700/50 p-3">