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}
/>