From a685e542aa1e3863b25cac52af6e12edebc6e3c7 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 2 Jun 2026 15:15:34 +0300 Subject: [PATCH] =?UTF-8?q?feat(sales-stats):=20=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4-=D0=BA-?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=D1=83=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=D1=85=20=D1=81?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StatCard получил опциональную дельту (стрелка + %), а страница статистики тянет сводку ещё и за предыдущий равный период и считает рост/падение для метрик периода: выручка, новые триалы, продления, доход с допов, ручные пополнения. Для «всего времени» сравнение не показывается (не с чем сравнивать). --- src/components/stats/StatCard.tsx | 24 +++++++++++-- src/pages/AdminSalesStats.tsx | 58 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx index c632fee..a983a6b 100644 --- a/src/components/stats/StatCard.tsx +++ b/src/components/stats/StatCard.tsx @@ -1,12 +1,22 @@ import { type ReactNode } from 'react'; +import { TREND_STYLES } from './constants'; + const DEFAULT_VALUE_CLASS = 'text-dark-100'; +export interface StatCardDelta { + /** Signed percent change vs the comparison period. */ + percent: number; + trend: 'up' | 'down' | 'stable'; +} + interface StatCardProps { label: string; value: string | number; icon?: ReactNode; valueClassName?: string; + /** Optional period-over-period change shown next to the value. */ + delta?: StatCardDelta | null; } export function StatCard({ @@ -14,15 +24,25 @@ export function StatCard({ value, icon, valueClassName = DEFAULT_VALUE_CLASS, + delta, }: StatCardProps) { + const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null; + return (
{icon} {label}
-
- {value} +
+ + {value} + + {trendStyle && ( + + {trendStyle.arrow} {Math.abs(delta!.percent)}% + + )}
); diff --git a/src/pages/AdminSalesStats.tsx b/src/pages/AdminSalesStats.tsx index 5fb400e..db40db5 100644 --- a/src/pages/AdminSalesStats.tsx +++ b/src/pages/AdminSalesStats.tsx @@ -19,6 +19,40 @@ import { type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits'; +type Delta = { percent: number; trend: 'up' | 'down' | 'stable' }; + +/** Same-length window immediately before the selected one, for period-over-period + * deltas. Returns null for "all time" — nothing meaningful to compare against. */ +function getPreviousPeriodParams(period: { + days?: number; + startDate?: string; + endDate?: string; +}): SalesStatsParams | null { + if (period.startDate && period.endDate) { + const start = new Date(period.startDate).getTime(); + const length = new Date(period.endDate).getTime() - start; + return { + start_date: new Date(start - length).toISOString(), + end_date: new Date(start).toISOString(), + }; + } + if (period.days !== undefined && period.days > 0) { + const dayMs = 86_400_000; + const now = Date.now(); + return { + start_date: new Date(now - 2 * period.days * dayMs).toISOString(), + end_date: new Date(now - period.days * dayMs).toISOString(), + }; + } + return null; +} + +function computeDelta(current: number, previous: number): Delta | null { + if (previous === 0) return current === 0 ? null : { percent: 100, trend: 'up' }; + const percent = Math.round(((current - previous) / previous) * 1000) / 10; + return { percent, trend: percent > 0 ? 'up' : percent < 0 ? 'down' : 'stable' }; +} + export default function AdminSalesStats() { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); @@ -52,6 +86,25 @@ export default function AdminSalesStats() { enabled: isValidPeriod, }); + const prevParams = useMemo(() => getPreviousPeriodParams(period), [period]); + const { data: prevSummary } = useQuery({ + queryKey: ['sales-stats', 'summary', prevParams], + queryFn: () => salesStatsApi.getSummary(prevParams as SalesStatsParams), + staleTime: SALES_STATS.STALE_TIME, + enabled: isValidPeriod && prevParams !== null, + }); + + const deltas = useMemo(() => { + if (!summary || !prevSummary) return null; + return { + revenue: computeDelta(summary.total_revenue_kopeks, prevSummary.total_revenue_kopeks), + newTrials: computeDelta(summary.new_trials, prevSummary.new_trials), + renewals: computeDelta(summary.renewals_count, prevSummary.renewals_count), + addonRevenue: computeDelta(summary.addon_revenue_kopeks, prevSummary.addon_revenue_kopeks), + manualTopup: computeDelta(summary.manual_topup_kopeks, prevSummary.manual_topup_kopeks), + }; + }, [summary, prevSummary]); + const tabs: { id: TabId; label: string }[] = [ { id: 'trials', label: t('admin.salesStats.tabs.trials') }, { id: 'sales', label: t('admin.salesStats.tabs.sales') }, @@ -93,6 +146,7 @@ export default function AdminSalesStats() { ) } valueClassName="text-success-400" + delta={deltas?.revenue} />