mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(sales-stats): душевные карточки с иконками + фикс скачка при смене периода
- StatCard: иконка в мягком цветном чипе (tone success/accent/warning/…) в духе статистики Remnawave; дельта под значением. Обратно совместимо (valueClassName). - Карточки сводки получили осмысленные иконки (доход/подписки/триалы/конверсия/ продления/допы/пополнения) и цветовые тоны. - Скачок при переключении периодов убран: на все запросы статистики добавлен placeholderData: keepPreviousData — старые данные остаются на экране, пока грузятся новые, без вспышки скелетона и прыжка вёрстки.
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
@@ -23,6 +23,7 @@ export function AddonsTab({ params }: AddonsTabProps) {
|
|||||||
queryKey: ['sales-stats', 'addons', params],
|
queryKey: ['sales-stats', 'addons', params],
|
||||||
queryFn: () => salesStatsApi.getAddons(params),
|
queryFn: () => salesStatsApi.getAddons(params),
|
||||||
staleTime: SALES_STATS.STALE_TIME,
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dailyChartData = useMemo(() => {
|
const dailyChartData = useMemo(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
@@ -27,6 +27,7 @@ export function DepositsTab({ params }: DepositsTabProps) {
|
|||||||
queryKey: ['sales-stats', 'deposits', params],
|
queryKey: ['sales-stats', 'deposits', params],
|
||||||
queryFn: () => salesStatsApi.getDeposits(params),
|
queryFn: () => salesStatsApi.getDeposits(params),
|
||||||
staleTime: SALES_STATS.STALE_TIME,
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]);
|
const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
@@ -22,6 +22,7 @@ export function RenewalsTab({ params }: RenewalsTabProps) {
|
|||||||
queryKey: ['sales-stats', 'renewals', params],
|
queryKey: ['sales-stats', 'renewals', params],
|
||||||
queryFn: () => salesStatsApi.getRenewals(params),
|
queryFn: () => salesStatsApi.getRenewals(params),
|
||||||
staleTime: SALES_STATS.STALE_TIME,
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
@@ -25,6 +25,7 @@ export function SalesTab({ params }: SalesTabProps) {
|
|||||||
queryKey: ['sales-stats', 'sales', params],
|
queryKey: ['sales-stats', 'sales', params],
|
||||||
queryFn: () => salesStatsApi.getSales(params),
|
queryFn: () => salesStatsApi.getSales(params),
|
||||||
staleTime: SALES_STATS.STALE_TIME,
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dailyByTariffData = useMemo(
|
const dailyByTariffData = useMemo(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
@@ -29,6 +29,7 @@ export function TrialsTab({ params }: TrialsTabProps) {
|
|||||||
queryKey: ['sales-stats', 'trials', params],
|
queryKey: ['sales-stats', 'trials', params],
|
||||||
queryFn: () => salesStatsApi.getTrials(params),
|
queryFn: () => salesStatsApi.getTrials(params),
|
||||||
staleTime: SALES_STATS.STALE_TIME,
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -2,20 +2,29 @@ import { type ReactNode } from 'react';
|
|||||||
|
|
||||||
import { TREND_STYLES } from './constants';
|
import { TREND_STYLES } from './constants';
|
||||||
|
|
||||||
const DEFAULT_VALUE_CLASS = 'text-dark-100';
|
|
||||||
|
|
||||||
export interface StatCardDelta {
|
export interface StatCardDelta {
|
||||||
/** Signed percent change vs the comparison period. */
|
/** Signed percent change vs the comparison period. */
|
||||||
percent: number;
|
percent: number;
|
||||||
trend: 'up' | 'down' | 'stable';
|
trend: 'up' | 'down' | 'stable';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Soft tinted chip + matching value colour, in the spirit of the Remnawave stats. */
|
||||||
|
const TONE = {
|
||||||
|
neutral: { chip: 'bg-dark-700/60 text-dark-300', value: 'text-dark-100' },
|
||||||
|
success: { chip: 'bg-success-500/15 text-success-400', value: 'text-success-400' },
|
||||||
|
accent: { chip: 'bg-accent-500/15 text-accent-400', value: 'text-accent-400' },
|
||||||
|
warning: { chip: 'bg-warning-500/15 text-warning-400', value: 'text-warning-400' },
|
||||||
|
error: { chip: 'bg-error-500/15 text-error-400', value: 'text-error-400' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
|
/** Tints the icon chip and (unless valueClassName is set) the value colour. */
|
||||||
|
tone?: keyof typeof TONE;
|
||||||
valueClassName?: string;
|
valueClassName?: string;
|
||||||
/** Optional period-over-period change shown next to the value. */
|
/** Optional period-over-period change shown under the value. */
|
||||||
delta?: StatCardDelta | null;
|
delta?: StatCardDelta | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,21 +32,27 @@ export function StatCard({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
icon,
|
icon,
|
||||||
valueClassName = DEFAULT_VALUE_CLASS,
|
tone = 'neutral',
|
||||||
|
valueClassName,
|
||||||
delta,
|
delta,
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
|
const toneStyle = TONE[tone];
|
||||||
|
const valueClass = valueClassName ?? toneStyle.value;
|
||||||
const trendStyle = delta ? (TREND_STYLES[delta.trend] ?? TREND_STYLES.stable) : null;
|
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 transition-colors hover:bg-dark-800/50">
|
||||||
<div className="flex items-center gap-1.5 text-sm text-dark-500">
|
<div className="flex items-center gap-2.5">
|
||||||
|
{icon && (
|
||||||
|
<span
|
||||||
|
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${toneStyle.chip}`}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
<div className="mt-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className={`truncate text-base font-semibold sm:text-lg ${valueClassName}`}>
|
<div className="truncate text-xs text-dark-500 sm:text-sm">{label}</div>
|
||||||
{value}
|
<div className={`truncate text-base font-semibold sm:text-lg ${valueClass}`}>{value}</div>
|
||||||
</div>
|
|
||||||
{trendStyle && (
|
{trendStyle && (
|
||||||
<div className={`mt-0.5 text-xs font-medium ${trendStyle.className}`}>
|
<div className={`mt-0.5 text-xs font-medium ${trendStyle.className}`}>
|
||||||
{trendStyle.arrow} {Math.abs(delta!.percent)}%
|
{trendStyle.arrow} {Math.abs(delta!.percent)}%
|
||||||
@@ -45,5 +60,6 @@ export function StatCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { SalesStatsParams } from '../api/adminSalesStats';
|
import type { SalesStatsParams } from '../api/adminSalesStats';
|
||||||
import { salesStatsApi } from '../api/adminSalesStats';
|
import { salesStatsApi } from '../api/adminSalesStats';
|
||||||
import { SALES_STATS } from '../constants/salesStats';
|
import { SALES_STATS } from '../constants/salesStats';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { AdminBackButton } from '../components/admin/AdminBackButton';
|
import { AdminBackButton } from '../components/admin/AdminBackButton';
|
||||||
|
import {
|
||||||
|
BanknotesIcon,
|
||||||
|
GiftIcon,
|
||||||
|
PercentIcon,
|
||||||
|
RepeatIcon,
|
||||||
|
RocketIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
SubscriptionIcon,
|
||||||
|
WalletIcon,
|
||||||
|
} from '../components/icons';
|
||||||
import { StatCard } from '../components/stats';
|
import { StatCard } from '../components/stats';
|
||||||
import {
|
import {
|
||||||
AddonsTab,
|
AddonsTab,
|
||||||
@@ -84,6 +94,7 @@ export default function AdminSalesStats() {
|
|||||||
queryFn: () => salesStatsApi.getSummary(params),
|
queryFn: () => salesStatsApi.getSummary(params),
|
||||||
staleTime: SALES_STATS.STALE_TIME,
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
enabled: isValidPeriod,
|
enabled: isValidPeriod,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const prevParams = useMemo(() => getPreviousPeriodParams(period), [period]);
|
const prevParams = useMemo(() => getPreviousPeriodParams(period), [period]);
|
||||||
@@ -92,6 +103,7 @@ export default function AdminSalesStats() {
|
|||||||
queryFn: () => salesStatsApi.getSummary(prevParams as SalesStatsParams),
|
queryFn: () => salesStatsApi.getSummary(prevParams as SalesStatsParams),
|
||||||
staleTime: SALES_STATS.STALE_TIME,
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
enabled: isValidPeriod && prevParams !== null,
|
enabled: isValidPeriod && prevParams !== null,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deltas = useMemo(() => {
|
const deltas = useMemo(() => {
|
||||||
@@ -146,33 +158,40 @@ export default function AdminSalesStats() {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
valueClassName="text-success-400"
|
icon={<BanknotesIcon className="h-5 w-5" />}
|
||||||
|
tone="success"
|
||||||
delta={deltas?.revenue}
|
delta={deltas?.revenue}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.salesStats.summary.activeSubs')}
|
label={t('admin.salesStats.summary.activeSubs')}
|
||||||
value={summaryLoading ? '...' : (summary?.active_subscriptions ?? 0)}
|
value={summaryLoading ? '...' : (summary?.active_subscriptions ?? 0)}
|
||||||
valueClassName="text-accent-400"
|
icon={<SubscriptionIcon className="h-5 w-5" />}
|
||||||
|
tone="accent"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.salesStats.summary.activeTrials')}
|
label={t('admin.salesStats.summary.activeTrials')}
|
||||||
value={summaryLoading ? '...' : (summary?.active_trials ?? 0)}
|
value={summaryLoading ? '...' : (summary?.active_trials ?? 0)}
|
||||||
|
icon={<GiftIcon className="h-5 w-5" />}
|
||||||
|
tone="neutral"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
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"
|
icon={<SparklesIcon className="h-5 w-5" />}
|
||||||
|
tone="accent"
|
||||||
delta={deltas?.newTrials}
|
delta={deltas?.newTrials}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('admin.salesStats.summary.conversion')}
|
label={t('admin.salesStats.summary.conversion')}
|
||||||
value={summaryLoading ? '...' : `${summary?.trial_to_paid_conversion ?? 0}%`}
|
value={summaryLoading ? '...' : `${summary?.trial_to_paid_conversion ?? 0}%`}
|
||||||
valueClassName="text-warning-400"
|
icon={<PercentIcon className="h-5 w-5" />}
|
||||||
|
tone="warning"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
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"
|
icon={<RepeatIcon className="h-5 w-5" />}
|
||||||
|
tone="success"
|
||||||
delta={deltas?.renewals}
|
delta={deltas?.renewals}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@@ -185,7 +204,8 @@ export default function AdminSalesStats() {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
valueClassName="text-accent-400"
|
icon={<RocketIcon className="h-5 w-5" />}
|
||||||
|
tone="accent"
|
||||||
delta={deltas?.addonRevenue}
|
delta={deltas?.addonRevenue}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
@@ -198,7 +218,8 @@ export default function AdminSalesStats() {
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
valueClassName="text-warning-400"
|
icon={<WalletIcon className="h-5 w-5" />}
|
||||||
|
tone="warning"
|
||||||
delta={deltas?.manualTopup}
|
delta={deltas?.manualTopup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user