mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(sales-stats): дельты период-к-периоду на карточках сводки
StatCard получил опциональную дельту (стрелка + %), а страница статистики тянет сводку ещё и за предыдущий равный период и считает рост/падение для метрик периода: выручка, новые триалы, продления, доход с допов, ручные пополнения. Для «всего времени» сравнение не показывается (не с чем сравнивать).
This commit is contained in:
@@ -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 (
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="flex items-center gap-1.5 text-sm text-dark-500">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className={`mt-1 truncate text-base font-semibold sm:text-lg ${valueClassName}`}>
|
||||
{value}
|
||||
<div className="mt-1 flex items-baseline gap-1.5">
|
||||
<span className={`truncate text-base font-semibold sm:text-lg ${valueClassName}`}>
|
||||
{value}
|
||||
</span>
|
||||
{trendStyle && (
|
||||
<span className={`shrink-0 text-xs font-medium ${trendStyle.className}`}>
|
||||
{trendStyle.arrow} {Math.abs(delta!.percent)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.activeSubs')}
|
||||
@@ -107,6 +161,7 @@ export default function AdminSalesStats() {
|
||||
label={t('admin.salesStats.summary.newTrials')}
|
||||
value={summaryLoading ? '...' : (summary?.new_trials ?? 0)}
|
||||
valueClassName="text-accent-400"
|
||||
delta={deltas?.newTrials}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.conversion')}
|
||||
@@ -117,6 +172,7 @@ export default function AdminSalesStats() {
|
||||
label={t('admin.salesStats.summary.renewals')}
|
||||
value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)}
|
||||
valueClassName="text-success-400"
|
||||
delta={deltas?.renewals}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.addonRevenue')}
|
||||
@@ -128,6 +184,7 @@ export default function AdminSalesStats() {
|
||||
)
|
||||
}
|
||||
valueClassName="text-accent-400"
|
||||
delta={deltas?.addonRevenue}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.manualTopup')}
|
||||
@@ -137,6 +194,7 @@ export default function AdminSalesStats() {
|
||||
: formatWithCurrency((summary?.manual_topup_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR)
|
||||
}
|
||||
valueClassName="text-warning-400"
|
||||
delta={deltas?.manualTopup}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user