mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
feat(sales-stats): дельты период-к-периоду на карточках сводки
StatCard получил опциональную дельту (стрелка + %), а страница статистики тянет сводку ещё и за предыдущий равный период и считает рост/падение для метрик периода: выручка, новые триалы, продления, доход с допов, ручные пополнения. Для «всего времени» сравнение не показывается (не с чем сравнивать).
This commit is contained in:
@@ -1,12 +1,22 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { TREND_STYLES } from './constants';
|
||||||
|
|
||||||
const DEFAULT_VALUE_CLASS = 'text-dark-100';
|
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 {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
valueClassName?: string;
|
valueClassName?: string;
|
||||||
|
/** Optional period-over-period change shown next to the value. */
|
||||||
|
delta?: StatCardDelta | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatCard({
|
export function StatCard({
|
||||||
@@ -14,15 +24,25 @@ export function StatCard({
|
|||||||
value,
|
value,
|
||||||
icon,
|
icon,
|
||||||
valueClassName = DEFAULT_VALUE_CLASS,
|
valueClassName = DEFAULT_VALUE_CLASS,
|
||||||
|
delta,
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
|
const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||||
<div className="flex items-center gap-1.5 text-sm text-dark-500">
|
<div className="flex items-center gap-1.5 text-sm text-dark-500">
|
||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={`mt-1 truncate text-base font-semibold sm:text-lg ${valueClassName}`}>
|
<div className="mt-1 flex items-baseline gap-1.5">
|
||||||
|
<span className={`truncate text-base font-semibold sm:text-lg ${valueClassName}`}>
|
||||||
{value}
|
{value}
|
||||||
|
</span>
|
||||||
|
{trendStyle && (
|
||||||
|
<span className={`shrink-0 text-xs font-medium ${trendStyle.className}`}>
|
||||||
|
{trendStyle.arrow} {Math.abs(delta!.percent)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,40 @@ import {
|
|||||||
|
|
||||||
type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits';
|
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() {
|
export default function AdminSalesStats() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
@@ -52,6 +86,25 @@ export default function AdminSalesStats() {
|
|||||||
enabled: isValidPeriod,
|
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 }[] = [
|
const tabs: { id: TabId; label: string }[] = [
|
||||||
{ id: 'trials', label: t('admin.salesStats.tabs.trials') },
|
{ id: 'trials', label: t('admin.salesStats.tabs.trials') },
|
||||||
{ id: 'sales', label: t('admin.salesStats.tabs.sales') },
|
{ id: 'sales', label: t('admin.salesStats.tabs.sales') },
|
||||||
@@ -93,6 +146,7 @@ export default function AdminSalesStats() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
valueClassName="text-success-400"
|
valueClassName="text-success-400"
|
||||||
|
delta={deltas?.revenue}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.salesStats.summary.activeSubs')}
|
label={t('admin.salesStats.summary.activeSubs')}
|
||||||
@@ -107,6 +161,7 @@ export default function AdminSalesStats() {
|
|||||||
label={t('admin.salesStats.summary.newTrials')}
|
label={t('admin.salesStats.summary.newTrials')}
|
||||||
value={summaryLoading ? '...' : (summary?.new_trials ?? 0)}
|
value={summaryLoading ? '...' : (summary?.new_trials ?? 0)}
|
||||||
valueClassName="text-accent-400"
|
valueClassName="text-accent-400"
|
||||||
|
delta={deltas?.newTrials}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.salesStats.summary.conversion')}
|
label={t('admin.salesStats.summary.conversion')}
|
||||||
@@ -117,6 +172,7 @@ export default function AdminSalesStats() {
|
|||||||
label={t('admin.salesStats.summary.renewals')}
|
label={t('admin.salesStats.summary.renewals')}
|
||||||
value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)}
|
value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)}
|
||||||
valueClassName="text-success-400"
|
valueClassName="text-success-400"
|
||||||
|
delta={deltas?.renewals}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.salesStats.summary.addonRevenue')}
|
label={t('admin.salesStats.summary.addonRevenue')}
|
||||||
@@ -128,6 +184,7 @@ export default function AdminSalesStats() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
valueClassName="text-accent-400"
|
valueClassName="text-accent-400"
|
||||||
|
delta={deltas?.addonRevenue}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.salesStats.summary.manualTopup')}
|
label={t('admin.salesStats.summary.manualTopup')}
|
||||||
@@ -137,6 +194,7 @@ export default function AdminSalesStats() {
|
|||||||
: formatWithCurrency((summary?.manual_topup_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR)
|
: formatWithCurrency((summary?.manual_topup_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR)
|
||||||
}
|
}
|
||||||
valueClassName="text-warning-400"
|
valueClassName="text-warning-400"
|
||||||
|
delta={deltas?.manualTopup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user