feat(sales-stats): карточки возвратов в сводке

Две карточки — количество и сумма возвратов за период (красный тон).
Сетку сводки перевёл на 2/5 (10 карточек делятся ровно, без пустых
ячеек). StatCard получил invertDelta: для «меньше — лучше» метрик рост
красится в красный, падение в зелёный (стрелка по факту). 4 локали.
This commit is contained in:
c0mrade
2026-06-02 16:53:30 +03:00
parent 40c684cc88
commit bd12113294
7 changed files with 47 additions and 6 deletions

View File

@@ -19,6 +19,8 @@ export interface SalesSummary {
trial_to_paid_conversion: number; trial_to_paid_conversion: number;
renewals_count: number; renewals_count: number;
addon_revenue_kopeks: number; addon_revenue_kopeks: number;
refunds_count: number;
refunds_kopeks: number;
} }
// ============ Trials ============ // ============ Trials ============

View File

@@ -26,6 +26,9 @@ interface StatCardProps {
valueClassName?: string; valueClassName?: string;
/** Optional period-over-period change shown under the value. */ /** Optional period-over-period change shown under the value. */
delta?: StatCardDelta | null; delta?: StatCardDelta | null;
/** For "lower is better" metrics (e.g. refunds): keep the real arrow but flip
* the colour, so a rise reads as bad (red) and a drop as good (green). */
invertDelta?: boolean;
} }
export function StatCard({ export function StatCard({
@@ -35,10 +38,18 @@ export function StatCard({
tone = 'neutral', tone = 'neutral',
valueClassName, valueClassName,
delta, delta,
invertDelta = false,
}: StatCardProps) { }: StatCardProps) {
const toneStyle = TONE[tone]; const toneStyle = TONE[tone];
const valueClass = valueClassName ?? toneStyle.value; const valueClass = valueClassName ?? toneStyle.value;
const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; const baseTrend = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null;
const trendStyle =
baseTrend && invertDelta && delta && delta.trend !== 'stable'
? {
arrow: baseTrend.arrow,
className: TREND_STYLES[delta.trend === 'up' ? 'down' : 'up'].className,
}
: baseTrend;
return ( return (
<div className="rounded-xl bg-dark-800/30 p-3 transition-colors hover:bg-dark-800/50"> <div className="rounded-xl bg-dark-800/30 p-3 transition-colors hover:bg-dark-800/50">

View File

@@ -1306,7 +1306,9 @@
"conversion": "Conversion", "conversion": "Conversion",
"renewals": "Renewals", "renewals": "Renewals",
"addonRevenue": "Add-on Revenue", "addonRevenue": "Add-on Revenue",
"manualTopup": "Manual Top-ups" "manualTopup": "Manual Top-ups",
"refundsCount": "Refunds",
"refundsAmount": "Refunded amount"
}, },
"trials": { "trials": {
"total": "Total Trials", "total": "Total Trials",

View File

@@ -1179,7 +1179,9 @@
"conversion": "تبدیل", "conversion": "تبدیل",
"renewals": "تمدید", "renewals": "تمدید",
"addonRevenue": "درآمد خدمات اضافی", "addonRevenue": "درآمد خدمات اضافی",
"manualTopup": "شارژ دستی" "manualTopup": "شارژ دستی",
"refundsCount": "بازپرداخت‌ها",
"refundsAmount": "مبلغ بازپرداخت"
}, },
"trials": { "trials": {
"total": "کل آزمایشی", "total": "کل آزمایشی",

View File

@@ -1328,7 +1328,9 @@
"conversion": "Конверсия", "conversion": "Конверсия",
"renewals": "Продления", "renewals": "Продления",
"addonRevenue": "Доп. услуги", "addonRevenue": "Доп. услуги",
"manualTopup": "Ручные пополнения" "manualTopup": "Ручные пополнения",
"refundsCount": "Возвраты",
"refundsAmount": "Сумма возвратов"
}, },
"trials": { "trials": {
"total": "Всего триалов", "total": "Всего триалов",

View File

@@ -1179,7 +1179,9 @@
"conversion": "转化率", "conversion": "转化率",
"renewals": "续订", "renewals": "续订",
"addonRevenue": "附加服务收入", "addonRevenue": "附加服务收入",
"manualTopup": "手动充值" "manualTopup": "手动充值",
"refundsCount": "退款",
"refundsAmount": "退款金额"
}, },
"trials": { "trials": {
"total": "试用总数", "total": "试用总数",

View File

@@ -13,6 +13,7 @@ import {
GiftIcon, GiftIcon,
PercentIcon, PercentIcon,
RepeatIcon, RepeatIcon,
ResetIcon,
RocketIcon, RocketIcon,
SparklesIcon, SparklesIcon,
TicketIcon, TicketIcon,
@@ -115,6 +116,7 @@ export default function AdminSalesStats() {
renewals: computeDelta(summary.renewals_count, prevSummary.renewals_count), renewals: computeDelta(summary.renewals_count, prevSummary.renewals_count),
addonRevenue: computeDelta(summary.addon_revenue_kopeks, prevSummary.addon_revenue_kopeks), addonRevenue: computeDelta(summary.addon_revenue_kopeks, prevSummary.addon_revenue_kopeks),
manualTopup: computeDelta(summary.manual_topup_kopeks, prevSummary.manual_topup_kopeks), manualTopup: computeDelta(summary.manual_topup_kopeks, prevSummary.manual_topup_kopeks),
refunds: computeDelta(summary.refunds_kopeks, prevSummary.refunds_kopeks),
}; };
}, [summary, prevSummary]); }, [summary, prevSummary]);
@@ -148,7 +150,7 @@ export default function AdminSalesStats() {
{t('admin.salesStats.loadError')} {t('admin.salesStats.loadError')}
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<StatCard <StatCard
label={t('admin.salesStats.summary.revenue')} label={t('admin.salesStats.summary.revenue')}
value={ value={
@@ -223,6 +225,24 @@ export default function AdminSalesStats() {
tone="warning" tone="warning"
delta={deltas?.manualTopup} delta={deltas?.manualTopup}
/> />
<StatCard
label={t('admin.salesStats.summary.refundsCount')}
value={summaryLoading ? '...' : (summary?.refunds_count ?? 0)}
icon={<ResetIcon className="h-5 w-5" />}
tone="error"
/>
<StatCard
label={t('admin.salesStats.summary.refundsAmount')}
value={
summaryLoading
? '...'
: formatWithCurrency((summary?.refunds_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR, 0)
}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="error"
delta={deltas?.refunds}
invertDelta
/>
</div> </div>
{/* Tabs */} {/* Tabs */}