From 5de43c1071aa1fabb63ce70989afde1e5f431059 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Thu, 4 Jun 2026 22:33:12 +0300 Subject: [PATCH] fix(admin-wheel): unify statistics rendering + fill config UI gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/locales/en.json | 11 ++- src/locales/fa.json | 11 ++- src/locales/ru.json | 11 ++- src/locales/zh.json | 11 ++- src/pages/AdminWheel.tsx | 203 ++++++++++++++++++++++++++------------- 5 files changed, 167 insertions(+), 80 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index b81f0de..32fdaad 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1756,7 +1756,8 @@ "dailyLimit": "Daily Spin Limit (0 = unlimited)", "minSubDays": "Min subscription days for day payment", "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.", "prizes": { @@ -1783,7 +1784,9 @@ "emoji": "Emoji", "color": "Color", "active": "Active", - "worth": "Worth" + "worth": "Worth", + "probability": "Manual probability (0–1)", + "probabilityHint": "Empty = auto by RTP" }, "promo": { "title": "Promo Code Settings", @@ -1801,7 +1804,9 @@ "times": "times", "times_one": "{{count}} time", "times_other": "{{count}} times", - "topWins": "Top Wins" + "topWins": "Top Wins", + "loadError": "Failed to load statistics", + "empty": "No spins yet" } }, "broadcasts": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 640e433..847874b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1432,7 +1432,8 @@ "promoPrefix": "پیشوند کد تخفیف", "spinCost": "هزینه چرخش", "limitsAndRtp": "محدودیت‌ها و RTP", - "promocodes": "کدهای تخفیف" + "promocodes": "کدهای تخفیف", + "promoValidityDays": "اعتبار کد تخفیف (روز)" }, "starsNotEnabledGlobally": "پرداخت با ستاره در روش‌های پرداخت فعال نیست. «Telegram Stars» را در بخش «روش‌های پرداخت» فعال کنید.", "prizes": { @@ -1459,7 +1460,9 @@ "emoji": "ایموجی", "color": "رنگ", "active": "فعال", - "worth": "ارزش" + "worth": "ارزش", + "probability": "احتمال دستی (۰–۱)", + "probabilityHint": "خالی = خودکار بر اساس RTP" }, "promo": { "title": "تنظیمات کد تخفیف", @@ -1476,7 +1479,9 @@ "prizeDistribution": "توزیع جوایز", "times": "بار", "times_other": "{{count}} بار", - "topWins": "برترین برنده‌ها" + "topWins": "برترین برنده‌ها", + "loadError": "بارگذاری آمار ناموفق بود", + "empty": "هنوز چرخشی انجام نشده" } }, "broadcasts": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 3ceed9a..588db82 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1782,7 +1782,8 @@ "dailyLimit": "Дневной лимит вращений (0 = безлимит)", "minSubDays": "Мин. дней подписки для оплаты днями", "promocodes": "Промокоды", - "promoPrefix": "Префикс промокодов" + "promoPrefix": "Префикс промокодов", + "promoValidityDays": "Срок действия промокода (дней)" }, "starsNotEnabledGlobally": "Оплата звёздами не включена в платёжных методах. Включите метод «Telegram Stars» в разделе «Платёжные методы».", "prizes": { @@ -1809,7 +1810,9 @@ "emoji": "Эмодзи", "color": "Цвет", "active": "Активен", - "worth": "Стоимость" + "worth": "Стоимость", + "probability": "Ручная вероятность (0–1)", + "probabilityHint": "Пусто — авто по RTP" }, "promo": { "title": "Настройки промокода", @@ -1828,7 +1831,9 @@ "times_one": "{{count}} раз", "times_few": "{{count}} раза", "times_many": "{{count}} раз", - "topWins": "Топ выигрышей" + "topWins": "Топ выигрышей", + "loadError": "Не удалось загрузить статистику", + "empty": "Пока нет вращений" } }, "broadcasts": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 7a78ee1..727becb 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1509,7 +1509,8 @@ "promoPrefix": "优惠码前缀", "spinCost": "旋转费用", "limitsAndRtp": "限制和RTP", - "promocodes": "促销码" + "promocodes": "促销码", + "promoValidityDays": "促销码有效期(天)" }, "starsNotEnabledGlobally": "星星支付未在支付方式中启用。请在「支付方式」中启用「Telegram Stars」。", "prizes": { @@ -1536,7 +1537,9 @@ "emoji": "表情", "color": "颜色", "active": "启用", - "worth": "价值" + "worth": "价值", + "probability": "手动概率 (0–1)", + "probabilityHint": "留空 = 按 RTP 自动" }, "promo": { "title": "优惠码设置", @@ -1553,7 +1556,9 @@ "prizeDistribution": "奖品分布", "times": "次", "times_other": "{{count}} 次", - "topWins": "最高奖品" + "topWins": "最高奖品", + "loadError": "无法加载统计数据", + "empty": "暂无抽奖记录" } }, "broadcasts": { diff --git a/src/pages/AdminWheel.tsx b/src/pages/AdminWheel.tsx index d64f0b3..21b9f05 100644 --- a/src/pages/AdminWheel.tsx +++ b/src/pages/AdminWheel.tsx @@ -40,8 +40,13 @@ import { StarIcon, TicketIcon, TrashIcon, + WalletIcon, + WheelIcon, XMarkIcon, } 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 { toNumber } from '../utils/inputHelpers'; @@ -166,6 +171,7 @@ export default function AdminWheel() { const confirmDelete = useDestructiveConfirm(); const { capabilities } = usePlatform(); const notify = useNotify(); + const { formatWithCurrency } = useCurrency(); const [activeTab, setActiveTab] = useState('settings'); const [expandedPrizeId, setExpandedPrizeId] = useState(null); @@ -184,6 +190,7 @@ export default function AdminWheel() { daily_spin_limit: number | ''; min_subscription_days_for_day_payment: number | ''; promo_prefix: string; + promo_validity_days: number | ''; } | null>(null); // Fetch config @@ -193,7 +200,11 @@ export default function AdminWheel() { }); // Fetch statistics - const { data: stats } = useQuery({ + const { + data: stats, + isLoading: statsLoading, + error: statsError, + } = useQuery({ queryKey: ['admin-wheel-stats'], queryFn: () => adminWheelApi.getStatistics(), enabled: activeTab === 'statistics', @@ -214,6 +225,7 @@ export default function AdminWheel() { daily_spin_limit: config.daily_spin_limit, min_subscription_days_for_day_payment: config.min_subscription_days_for_day_payment, 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.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 const updateConfigMutation = useMutation({ @@ -669,6 +682,26 @@ export default function AdminWheel() { className="input w-full" /> +
+ + { + 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" + /> +
@@ -702,6 +735,10 @@ export default function AdminWheel() { settingsForm.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} @@ -833,79 +870,82 @@ export default function AdminWheel() { )} {/* Statistics Tab */} - {activeTab === 'statistics' && stats && ( + {activeTab === 'statistics' && statsLoading && ( +
+
+
+ )} + + {activeTab === 'statistics' && !statsLoading && statsError && ( +
+ {t('admin.wheel.statistics.loadError')} +
+ )} + + {activeTab === 'statistics' && + !statsLoading && + !statsError && + stats && + stats.total_spins === 0 && ( +
+ {t('admin.wheel.statistics.empty')} +
+ )} + + {activeTab === 'statistics' && stats && stats.total_spins > 0 && (
- {/* Stats cards */} -
-
-
{stats.total_spins}
-
{t('admin.wheel.statistics.totalSpins')}
-
-
-
- {(stats.total_revenue_kopeks / 100).toFixed(0)}₽ -
-
{t('admin.wheel.statistics.revenue')}
-
-
-
- {(stats.total_payout_kopeks / 100).toFixed(0)}₽ -
-
{t('admin.wheel.statistics.payouts')}
-
-
-
- {stats.actual_rtp_percent.toFixed(1)}% -
-
- {t('admin.wheel.statistics.actualRtp')} ({t('admin.wheel.statistics.targetRtp')}:{' '} - {stats.configured_rtp_percent}%) -
-
+ {/* Headline metrics — shared StatCard, like the rest of the admin stats */} +
+ } + tone="accent" + /> + } + tone="success" + /> + } + tone="warning" + /> + } + tone={stats.actual_rtp_percent <= stats.configured_rtp_percent ? 'success' : 'error'} + />
- {/* Prize distribution */} + {/* Prize distribution — shared BreakdownList (ranked bars + share %) */} {stats.prizes_distribution.length > 0 && ( -
-

- {t('admin.wheel.statistics.prizeDistribution')} -

-
- {stats.prizes_distribution.map((prize, i) => ( -
- {prize.display_name} - - {t('admin.wheel.statistics.times', { count: prize.count })} - -
- ))} -
-
+ ({ + key: prize.display_name, + label: prize.display_name, + value: prize.count, + }))} + valueFormatter={(v) => t('admin.wheel.statistics.times', { count: v })} + /> )} - {/* Top wins */} + {/* Top wins — same BreakdownList, ranked by win value */} {stats.top_wins.length > 0 && ( -
-

- {t('admin.wheel.statistics.topWins')} -

-
- {stats.top_wins.slice(0, 5).map((win, i) => ( -
- {win.username || `User #${win.user_id}`} - - {win.prize_display_name} ({(win.prize_value_kopeks / 100).toFixed(0)}₽) - -
- ))} -
-
+ ({ + key: `${win.user_id}-${i}`, + label: `${win.username || `#${win.user_id}`} · ${win.prize_display_name}`, + value: win.prize_value_kopeks / 100, + }))} + valueFormatter={(v) => formatWithCurrency(v, 0)} + /> )}
)} @@ -1097,6 +1137,33 @@ function InlinePrizeForm({
+ {/* Manual probability override (optional; empty = auto by RTP) */} +
+ + { + 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" + /> +

+ {t('admin.wheel.prizes.fields.probabilityHint')} +

+
+ {/* Promocode settings */} {formData.prize_type === 'promocode' && (