Merge pull request #447 from BEDOLAGA-DEV/dev

Релиз: переработка статистики продаж, новый датапикер, INCY
This commit is contained in:
c0mrade
2026-06-02 22:52:28 +03:00
committed by GitHub
30 changed files with 1053 additions and 166 deletions

43
package-lock.json generated
View File

@@ -53,6 +53,7 @@
"jsencrypt": "^3.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-icons": "^5.6.0",
@@ -392,6 +393,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
"license": "MIT"
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@@ -4564,6 +4571,16 @@
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -6870,6 +6887,32 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"@types/react": ">=16.8.0",
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",

View File

@@ -61,6 +61,7 @@
"jsencrypt": "^3.5.4",
"qrcode.react": "^4.2.0",
"react": "^19.2.4",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-icons": "^5.6.0",

View File

@@ -28,11 +28,11 @@ const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as
/** Pages reachable from bottom nav — treat as top-level (no back button). */
const BOTTOM_NAV_PATHS = ['/', '/subscriptions', '/balance', '/referral', '/support', '/wheel'];
/** Matches /subscriptions/:numericId. Single-tariff users land here straight
* from bot deep-links, and their /subscriptions list auto-redirects right back
* to this page (Subscriptions.tsx). So on a genuine deep-link entry (in-app
* navigation depth 0) we hide the back button and let Telegram surface its
* native Close (X); when there IS in-app history we show it and navigate back. */
/** Matches /subscriptions/:numericId. When the user has a single tariff and at
* most one subscription, the /subscriptions list auto-redirects straight back
* here (Subscriptions.tsx), so this page is effectively top-level: we hide the
* back button and let Telegram surface its native Close (X). Multi-tariff users
* (or anyone with >1 subscription) keep a real Back to their meaningful list. */
const SUBSCRIPTION_DETAIL_RE = /^\/subscriptions\/\d+\/?$/;
function TelegramBackButton() {
@@ -71,27 +71,39 @@ function TelegramBackButton() {
enabled: isInTelegramWebApp(),
});
const isMultiTariff = subData?.multi_tariff_enabled ?? false;
const subsCount = subData?.subscriptions?.length ?? 0;
// The /subscriptions list silently redirects straight back to the open detail
// page when there is a single tariff and at most one subscription
// (Subscriptions.tsx). In that mode the detail page IS the top-level screen —
// there is no meaningful "back" target. Inverse of the handler's `listIsSafe`.
// Defaults to true on a cold cache (isMultiTariff=false, subsCount=0): hiding
// Back is fail-closed — far better than briefly arming the looping Back.
const listRedirectsToDetail = !isMultiTariff && subsCount <= 1;
// Refs so the stable back handler (memoised with []) reads fresh values
// without re-subscribing — re-subscription lets a component's local handler
// overwrite ours via Telegram's singleton onBackButtonClick (issue #436).
const isMultiTariffRef = useRef(isMultiTariff);
isMultiTariffRef.current = isMultiTariff;
const subsCountRef = useRef(subData?.subscriptions?.length ?? 0);
subsCountRef.current = subData?.subscriptions?.length ?? 0;
const subsCountRef = useRef(subsCount);
subsCountRef.current = subsCount;
useEffect(() => {
const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname);
const isSingleTariffDetailDeepLink =
!isMultiTariff && SUBSCRIPTION_DETAIL_RE.test(location.pathname) && depthRef.current === 0;
// Depth-independent on purpose: whether the user deep-linked in or navigated
// here in-app, a single-tariff detail whose list just bounces back has no
// real "back" target. Showing Back here is exactly what looped through the
// redirecting /subscriptions list (#436); hiding it always surfaces Close.
const isRedirectingSubscriptionDetail =
listRedirectsToDetail && SUBSCRIPTION_DETAIL_RE.test(location.pathname);
try {
if (isTopLevel || isSingleTariffDetailDeepLink) {
if (isTopLevel || isRedirectingSubscriptionDetail) {
hideBackButton();
} else {
showBackButton();
}
} catch {}
}, [location, isMultiTariff]);
}, [location, listRedirectsToDetail]);
// Stable handler — ref prevents re-subscription on every render
const handler = useCallback(() => {

View File

@@ -16,6 +16,8 @@ export interface SalesSummary {
active_subscriptions: number;
active_trials: number;
new_trials: number;
new_paid_subscriptions: number;
expired_subscriptions: number;
trial_to_paid_conversion: number;
renewals_count: number;
addon_revenue_kopeks: number;
@@ -165,6 +167,21 @@ export interface DepositsStats {
daily_by_method: DailyDepositByMethodItem[];
}
export interface GatewaySuccessItem {
method: string;
total: number;
paid: number;
success_rate: number;
}
export interface PaymentHealth {
total_attempts: number;
total_paid: number;
success_rate: number;
failed_purchases: number;
by_gateway: GatewaySuccessItem[];
}
// ============ API ============
export const salesStatsApi = {
@@ -197,4 +214,9 @@ export const salesStatsApi = {
const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params });
return response.data;
},
getPaymentHealth: async (params: SalesStatsParams = {}): Promise<PaymentHealth> => {
const response = await apiClient.get('/cabinet/admin/stats/sales/payment-health', { params });
return response.data;
},
};

View File

@@ -0,0 +1,113 @@
import * as Popover from '@radix-ui/react-popover';
import { enUS, faIR, ru, zhCN } from 'date-fns/locale';
import { useState } from 'react';
import { DayPicker, type Matcher } from 'react-day-picker';
import { useTranslation } from 'react-i18next';
import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from './icons';
import 'react-day-picker/style.css';
interface DateFieldProps {
/** Selected date as 'YYYY-MM-DD', or '' when empty. */
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Inclusive bounds as 'YYYY-MM-DD'. */
min?: string;
max?: string;
/** Override the trigger button classes. */
className?: string;
}
const LOCALES = { ru, en: enUS, zh: zhCN, fa: faIR } as const;
const pad = (n: number) => String(n).padStart(2, '0');
const toISO = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const parseISO = (s: string): Date | undefined => {
if (!s) return undefined;
const [y, m, d] = s.split('-').map(Number);
if (!y || !m || !d) return undefined;
return new Date(y, m - 1, d);
};
function NavChevron({ orientation }: { orientation?: 'left' | 'right' | 'up' | 'down' }) {
return orientation === 'right' ? (
<ChevronRightIcon className="h-4 w-4" />
) : (
<ChevronLeftIcon className="h-4 w-4" />
);
}
/**
* Dark-themed date field — Radix Popover trigger + react-day-picker calendar.
* Drop-in replacement for `<input type="date">`: takes/returns 'YYYY-MM-DD'.
* Month/weekday names follow the UI language (date-fns locale), navigation uses
* the shared barrel chevrons, theming via the .rdp-dark variable overrides.
*/
export function DateField({ value, onChange, placeholder, min, max, className }: DateFieldProps) {
const { i18n } = useTranslation();
const [open, setOpen] = useState(false);
const selected = parseISO(value);
const locale = LOCALES[i18n.language as keyof typeof LOCALES] ?? enUS;
// Numeric format keeps a constant button width across dates and locales
// (a "short" month name varies in length and makes the trigger jitter).
const display = selected
? selected.toLocaleDateString(i18n.language, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: '';
const disabled: Matcher[] = [];
const minDate = parseISO(min ?? '');
const maxDate = parseISO(max ?? '');
if (minDate) disabled.push({ before: minDate });
if (maxDate) disabled.push({ after: maxDate });
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<button
type="button"
className={
className ??
'flex min-w-[8rem] items-center justify-start gap-2 whitespace-nowrap rounded-lg border border-dark-600 bg-dark-800 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:border-dark-500'
}
>
<CalendarIcon className="h-4 w-4 shrink-0 text-dark-500" />
<span className={`truncate ${selected ? '' : 'text-dark-500'}`}>
{display || placeholder}
</span>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={6}
collisionPadding={8}
className="rdp-dark z-50 max-w-[calc(100vw-1rem)] rounded-xl border border-dark-700 bg-dark-800 p-2 shadow-xl"
>
<DayPicker
mode="single"
locale={locale}
selected={selected}
defaultMonth={selected}
disabled={disabled}
fixedWeeks
showOutsideDays
components={{ Chevron: NavChevron }}
onSelect={(d) => {
if (d) {
onChange(toISO(d));
setOpen(false);
}
}}
/>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { CalendarIcon, XIcon } from '../TrafficIcons';
import { DateField } from '../../../DateField';
// ──────────────────────────────────────────────────────────────────
// PeriodSelector — switches between fixed period tabs (1/3/7/14/30
@@ -41,25 +42,21 @@ export function PeriodSelector({
if (dateMode) {
return (
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4" />
<div className="flex flex-wrap items-center gap-2">
<CalendarIcon className="h-4 w-4 shrink-0" />
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateFrom')}</span>
<input
type="date"
<DateField
value={customStart}
min={minDate}
max={customEnd || today}
onChange={(e) => onCustomStartChange(e.target.value)}
className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none"
onChange={onCustomStartChange}
/>
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateTo')}</span>
<input
type="date"
<DateField
value={customEnd}
min={customStart || minDate}
max={today}
onChange={(e) => onCustomEndChange(e.target.value)}
className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none"
onChange={onCustomEndChange}
/>
<button
onClick={onToggleDateMode}

View File

@@ -1,15 +1,22 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { SALES_STATS } from '../../constants/salesStats';
import { useCurrency } from '../../hooks/useCurrency';
import {
BanknotesIcon,
CardIcon,
DevicesIcon,
PlusIcon,
TrafficIcon,
} from '../../components/icons';
import { StatCard } from '../stats';
import { BreakdownList } from './BreakdownList';
import { DualAreaChart } from './DualAreaChart';
import { SimpleBarChart } from './SimpleBarChart';
interface AddonsTabProps {
params: SalesStatsParams;
@@ -23,6 +30,7 @@ export function AddonsTab({ params }: AddonsTabProps) {
queryKey: ['sales-stats', 'addons', params],
queryFn: () => salesStatsApi.getAddons(params),
staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
});
const dailyChartData = useMemo(() => {
@@ -51,40 +59,49 @@ export function AddonsTab({ params }: AddonsTabProps) {
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
const packageBarData = data.by_package.map((item) => ({
name: `${item.traffic_gb} GB`,
const packageBreakdown = data.by_package.map((item) => ({
key: String(item.traffic_gb),
label: `${item.traffic_gb} GB`,
value: item.count,
}));
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5 max-lg:[&>*:last-child:nth-child(odd)]:col-span-2">
<StatCard
label={t('admin.salesStats.addons.totalPurchases')}
value={data.total_purchases}
icon={<PlusIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.salesStats.addons.totalGb')}
value={`${data.total_gb_purchased} GB`}
icon={<TrafficIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.salesStats.addons.revenue')}
value={formatWithCurrency(data.addon_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
value={formatWithCurrency(data.addon_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR, 0)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.salesStats.addons.devicePurchases')}
value={data.device_purchases}
icon={<DevicesIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.salesStats.addons.deviceRevenue')}
value={formatWithCurrency(data.device_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
value={formatWithCurrency(data.device_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR, 0)}
icon={<CardIcon className="h-5 w-5" />}
tone="success"
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<SimpleBarChart data={packageBarData} title={t('admin.salesStats.addons.byPackage')} />
<BreakdownList title={t('admin.salesStats.addons.byPackage')} items={packageBreakdown} />
<DualAreaChart
data={dailyChartData}
title={t('admin.salesStats.addons.dailyChart')}

View File

@@ -0,0 +1,80 @@
import { type ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { SALES_STATS } from '../../constants/salesStats';
export interface BreakdownItem {
key: string;
label: string;
value: number;
color?: string;
icon?: ReactNode;
}
interface BreakdownListProps {
title: string;
items: BreakdownItem[];
/** Formats the value shown on the right (e.g. money). Defaults to the raw number. */
valueFormatter?: (value: number) => string;
}
/**
* Categorical breakdown rendered as a ranked list instead of a bar chart:
* icon/colour dot + label + value + share-% + a proportional mini bar. Far more
* readable than a recharts bar for "by method / by tariff / by package" data.
*/
export function BreakdownList({ title, items, valueFormatter }: BreakdownListProps) {
const { t } = useTranslation();
const { sorted, total, max } = useMemo(() => {
const sortedItems = [...items].sort((a, b) => b.value - a.value);
const totalValue = sortedItems.reduce((sum, item) => sum + item.value, 0);
const maxValue = sortedItems.length > 0 ? sortedItems[0].value : 0;
return { sorted: sortedItems, total: totalValue, max: maxValue };
}, [items]);
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
{sorted.length === 0 ? (
<div className="py-6 text-center text-sm text-dark-400">{t('common.noData')}</div>
) : (
<div className="space-y-2.5">
{sorted.map((item, index) => {
const color =
item.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length];
const share = total > 0 ? (item.value / total) * 100 : 0;
const barWidth = max > 0 ? (item.value / max) * 100 : 0;
return (
<div key={item.key}>
<div className="flex items-center gap-2">
{item.icon ?? (
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
)}
<span className="min-w-0 flex-1 truncate text-sm text-dark-200">
{item.label}
</span>
<span className="shrink-0 text-sm font-semibold text-dark-100">
{valueFormatter ? valueFormatter(item.value) : item.value}
</span>
<span className="w-12 shrink-0 text-right text-xs tabular-nums text-dark-500">
{share.toFixed(1)}%
</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-dark-800/60">
<div
className="h-full rounded-full transition-all"
style={{ width: `${barWidth}%`, backgroundColor: color }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
@@ -7,11 +7,14 @@ import { salesStatsApi } from '../../api/adminSalesStats';
import { METHOD_LABELS } from '../../constants/paymentMethods';
import { SALES_STATS } from '../../constants/salesStats';
import { useCurrency } from '../../hooks/useCurrency';
import { BanknotesIcon, CardIcon, WalletIcon } from '../../components/icons';
import { StatCard } from '../stats';
import { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
import PaymentMethodIcon from '../PaymentMethodIcon';
import { BreakdownList } from './BreakdownList';
import { SimpleAreaChart } from './SimpleAreaChart';
import { SimpleBarChart } from './SimpleBarChart';
import { StackedBarChart } from './StackedBarChart';
interface DepositsTabProps {
params: SalesStatsParams;
@@ -25,15 +28,18 @@ export function DepositsTab({ params }: DepositsTabProps) {
queryKey: ['sales-stats', 'deposits', params],
queryFn: () => salesStatsApi.getDeposits(params),
staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
});
const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]);
const methodBarData = useMemo(
const methodBreakdown = useMemo(
() =>
data?.by_method.map((item) => ({
name: METHOD_LABELS[item.method] || item.method,
key: item.method,
label: METHOD_LABELS[item.method] || item.method,
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
icon: <PaymentMethodIcon method={item.method} className="h-5 w-5 shrink-0" />,
})) ?? [],
[data?.by_method],
);
@@ -77,21 +83,26 @@ export function DepositsTab({ params }: DepositsTabProps) {
<StatCard
label={t('admin.salesStats.deposits.totalDeposits')}
value={data.total_deposits}
icon={<WalletIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.salesStats.deposits.totalAmount')}
value={formatWithCurrency(data.total_amount_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
value={formatWithCurrency(data.total_amount_kopeks / SALES_STATS.KOPEKS_DIVISOR, 0)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.salesStats.deposits.avgDeposit')}
value={formatWithCurrency(data.avg_deposit_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
icon={<CardIcon className="h-5 w-5" />}
tone="neutral"
/>
</div>
<SimpleBarChart
data={methodBarData}
<BreakdownList
title={t('admin.salesStats.deposits.byMethod')}
items={methodBreakdown}
valueFormatter={formatValue}
/>
@@ -102,11 +113,9 @@ export function DepositsTab({ params }: DepositsTabProps) {
valueLabel={t('admin.salesStats.deposits.revenue')}
/>
<MultiSeriesAreaChart
<StackedBarChart
data={dailyByMethodData}
title={t('admin.salesStats.deposits.dailyByMethod')}
chartId="deposits-daily-by-method"
valueLabel={t('admin.salesStats.deposits.revenue')}
valueFormatter={formatValue}
/>
</div>

View File

@@ -0,0 +1,115 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { METHOD_LABELS } from '../../constants/paymentMethods';
import { SALES_STATS } from '../../constants/salesStats';
import { CardIcon, PercentIcon, WarningIcon } from '../../components/icons';
import PaymentMethodIcon from '../PaymentMethodIcon';
import { StatCard } from '../stats';
interface PaymentHealthTabProps {
params: SalesStatsParams;
}
/** Green when healthy, amber when shaky, red when most attempts fail. */
function rateColor(rate: number): string {
if (rate >= 90) return 'text-success-400';
if (rate >= 70) return 'text-warning-400';
return 'text-error-400';
}
function barColor(rate: number): string {
if (rate >= 90) return 'bg-success-500';
if (rate >= 70) return 'bg-warning-500';
return 'bg-error-500';
}
export function PaymentHealthTab({ params }: PaymentHealthTabProps) {
const { t } = useTranslation();
const { data, isLoading, isError } = useQuery({
queryKey: ['sales-stats', 'payment-health', params],
queryFn: () => salesStatsApi.getPaymentHealth(params),
staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
});
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
))}
</div>
);
}
if (isError || !data) {
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label={t('admin.salesStats.payments.successRate')}
value={`${data.success_rate}%`}
icon={<PercentIcon className="h-5 w-5" />}
tone={data.success_rate >= 90 ? 'success' : data.success_rate >= 70 ? 'warning' : 'error'}
/>
<StatCard
label={t('admin.salesStats.payments.attempts')}
value={data.total_attempts}
icon={<CardIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.salesStats.payments.failedPurchases')}
value={data.failed_purchases}
icon={<WarningIcon className="h-5 w-5" />}
tone="warning"
/>
</div>
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">
{t('admin.salesStats.payments.byGateway')}
</h4>
{data.by_gateway.length === 0 ? (
<div className="py-6 text-center text-sm text-dark-400">{t('common.noData')}</div>
) : (
<div className="space-y-3">
{data.by_gateway.map((g) => (
<div key={g.method} className="flex items-center gap-3">
<PaymentMethodIcon method={g.method} className="h-5 w-5 shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm text-dark-200">
{METHOD_LABELS[g.method] || g.method}
</span>
<span className="shrink-0 text-xs text-dark-500">
{g.paid}/{g.total}
</span>
</div>
<div className="mt-1 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-dark-700/60">
<div
className={`h-full rounded-full ${barColor(g.success_rate)}`}
style={{ width: `${g.success_rate}%` }}
/>
</div>
<span className={`shrink-0 text-xs font-medium ${rateColor(g.success_rate)}`}>
{g.success_rate}%
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -2,6 +2,8 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SALES_STATS } from '../../constants/salesStats';
import { getMonthToDateRange, isMonthToDate } from '../../utils/period';
import { DateField } from '../DateField';
interface PeriodSelectorProps {
value: { days?: number; startDate?: string; endDate?: string };
@@ -24,63 +26,61 @@ export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
onChange({ days });
};
const handleCustomToggle = () => {
setShowCustom((prev) => !prev);
const handleThisMonth = () => {
setShowCustom(false);
onChange({ days: undefined, ...getMonthToDateRange() });
};
const handleCustomToggle = () => setShowCustom((prev) => !prev);
const handleDateChange = (field: 'startDate' | 'endDate', dateStr: string) => {
onChange({
...value,
days: undefined,
[field]: dateStr,
});
onChange({ ...value, days: undefined, [field]: dateStr });
};
const isPresetActive = (days: number) => !showCustom && value.days === days;
const buttonClass = (active: boolean) =>
`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
active
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
}`;
const mtdActive = !showCustom && isMonthToDate(value);
return (
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={handleThisMonth} className={buttonClass(mtdActive)}>
{t('admin.salesStats.period.thisMonth')}
</button>
{SALES_STATS.PERIOD_PRESETS.map((days) => (
<button
key={days}
type="button"
onClick={() => handlePreset(days)}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
isPresetActive(days)
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
}`}
className={buttonClass(!showCustom && value.days === days)}
>
{presetLabels[days]}
</button>
))}
<button
type="button"
onClick={handleCustomToggle}
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
showCustom
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
}`}
>
<button type="button" onClick={handleCustomToggle} className={buttonClass(showCustom)}>
{t('admin.salesStats.period.custom')}
</button>
{showCustom && (
<div className="flex items-center gap-2">
<input
type="date"
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
<DateField
value={value.startDate || ''}
onChange={(e) => handleDateChange('startDate', e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
onChange={(v) => handleDateChange('startDate', v)}
max={value.endDate}
placeholder={t('admin.salesStats.period.from')}
/>
<span className="text-dark-500">{'\u2014'}</span>
<input
type="date"
<span className="text-dark-500">{''}</span>
<DateField
value={value.endDate || ''}
onChange={(e) => handleDateChange('endDate', e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
onChange={(v) => handleDateChange('endDate', v)}
min={value.startDate}
placeholder={t('admin.salesStats.period.to')}
/>
</div>
)}

View File

@@ -1,10 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { SALES_STATS } from '../../constants/salesStats';
import { useCurrency } from '../../hooks/useCurrency';
import { BanknotesIcon, PercentIcon, RepeatIcon } from '../../components/icons';
import { StatCard } from '../stats';
import { TREND_STYLES } from '../stats/constants';
@@ -22,6 +23,7 @@ export function RenewalsTab({ params }: RenewalsTabProps) {
queryKey: ['sales-stats', 'renewals', params],
queryFn: () => salesStatsApi.getRenewals(params),
staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
});
if (isLoading) {
@@ -48,16 +50,23 @@ export function RenewalsTab({ params }: RenewalsTabProps) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard label={t('admin.salesStats.renewals.total')} value={data.total_renewals} />
<StatCard
label={t('admin.salesStats.renewals.total')}
value={data.total_renewals}
icon={<RepeatIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.salesStats.renewals.rate')}
value={`${data.renewal_rate}%`}
valueClassName="text-success-400"
icon={<PercentIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.salesStats.renewals.revenue')}
value={formatWithCurrency(data.total_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
value={formatWithCurrency(data.total_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR, 0)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
/>
</div>

View File

@@ -1,17 +1,18 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { SALES_STATS } from '../../constants/salesStats';
import { useCurrency } from '../../hooks/useCurrency';
import { BanknotesIcon, CardIcon, TicketIcon, TrophyIcon } from '../../components/icons';
import { StatCard } from '../stats';
import { BreakdownList } from './BreakdownList';
import { DonutChart } from './DonutChart';
import { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
import { SimpleAreaChart } from './SimpleAreaChart';
import { SimpleBarChart } from './SimpleBarChart';
import { StackedBarChart } from './StackedBarChart';
interface SalesTabProps {
params: SalesStatsParams;
@@ -25,6 +26,7 @@ export function SalesTab({ params }: SalesTabProps) {
queryKey: ['sales-stats', 'sales', params],
queryFn: () => salesStatsApi.getSales(params),
staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
});
const dailyByTariffData = useMemo(
@@ -48,8 +50,9 @@ export function SalesTab({ params }: SalesTabProps) {
return <div className="py-8 text-center text-error-400">{t('admin.salesStats.loadError')}</div>;
}
const tariffBarData = data.by_tariff.map((item) => ({
name: item.tariff_name,
const tariffBreakdown = data.by_tariff.map((item) => ({
key: String(item.tariff_id),
label: item.tariff_name,
value: item.count,
}));
@@ -65,18 +68,35 @@ export function SalesTab({ params }: SalesTabProps) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard label={t('admin.salesStats.sales.totalSales')} value={data.total_sales} />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.salesStats.sales.totalSales')}
value={data.total_sales}
icon={<TicketIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.salesStats.sales.totalRevenue')}
value={formatWithCurrency(data.total_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR, 0)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.salesStats.sales.avgOrder')}
value={formatWithCurrency(data.avg_order_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
icon={<CardIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.salesStats.sales.topTariff')}
value={data.top_tariff_name}
icon={<TrophyIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard label={t('admin.salesStats.sales.topTariff')} value={data.top_tariff_name} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<SimpleBarChart data={tariffBarData} title={t('admin.salesStats.sales.byTariff')} />
<BreakdownList title={t('admin.salesStats.sales.byTariff')} items={tariffBreakdown} />
<DonutChart data={periodPieData} title={t('admin.salesStats.sales.byPeriod')} />
</div>
@@ -87,11 +107,10 @@ export function SalesTab({ params }: SalesTabProps) {
valueLabel={t('admin.salesStats.summary.revenue')}
/>
<MultiSeriesAreaChart
<StackedBarChart
data={dailyByTariffData}
title={t('admin.salesStats.sales.dailyByTariff')}
chartId="sales-daily-by-tariff"
valueLabel={t('admin.salesStats.sales.subscriptions')}
valueFormatter={(v) => String(v)}
/>
</div>
);

View File

@@ -0,0 +1,135 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { SALES_STATS } from '../../constants/salesStats';
import { useChartColors } from '../../hooks/useChartColors';
interface StackedBarChartProps {
data: { date: string; key: string; value: number }[];
title: string;
valueFormatter?: (value: number) => string;
height?: number;
}
/**
* Composition-over-time as a STACKED bar chart. Replaces the overlapping
* multi-series area chart for "daily by method / by tariff" — stacked bars read
* far cleaner (no layered translucent areas hiding each other) and the tooltip
* shows every series for the hovered day plus the day total.
*/
export function StackedBarChart({
data,
title,
valueFormatter,
height = SALES_STATS.CHART.HEIGHT,
}: StackedBarChartProps) {
const { t, i18n } = useTranslation();
const colors = useChartColors();
const { chartData, seriesKeys } = useMemo(() => {
const dateMap = new Map<string, Record<string, number>>();
const keySet = new Set<string>();
for (const item of data) {
keySet.add(item.key);
const existing = dateMap.get(item.date) || {};
existing[item.key] = (existing[item.key] || 0) + item.value;
dateMap.set(item.date, existing);
}
const keys = Array.from(keySet).sort();
const sortedDates = Array.from(dateMap.keys()).sort();
const pivoted = sortedDates.map((date) => {
const row: Record<string, string | number> = {
date,
label: new Date(date + 'T00:00:00').toLocaleDateString(i18n.language, {
month: 'short',
day: 'numeric',
}),
};
const values = dateMap.get(date) || {};
for (const key of keys) {
row[key] = values[key] || 0;
}
return row;
});
return { chartData: pivoted, seriesKeys: keys };
}, [data, i18n.language]);
if (data.length === 0) {
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
{t('common.noData')}
</div>
</div>
);
}
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
<ResponsiveContainer width="100%" height={height}>
<BarChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
<CartesianGrid
strokeDasharray={SALES_STATS.GRID_DASH}
stroke={colors.grid}
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
tickLine={false}
axisLine={false}
width={SALES_STATS.AXIS.WIDTH}
allowDecimals={false}
/>
<Tooltip
cursor={{ fill: colors.grid, fillOpacity: 0.2 }}
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
color: colors.label,
}}
labelStyle={{ color: colors.label }}
itemStyle={{ color: colors.label }}
formatter={(value: number | undefined, name: string | undefined) => [
valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0),
name || '',
]}
/>
<Legend />
{seriesKeys.map((key, index) => (
<Bar
key={key}
dataKey={key}
name={key}
stackId="a"
fill={SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
radius={index === seriesKeys.length - 1 ? [4, 4, 0, 0] : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { SalesStatsParams } from '../../api/adminSalesStats';
import { salesStatsApi } from '../../api/adminSalesStats';
import { SALES_STATS } from '../../constants/salesStats';
import { GiftIcon, PercentIcon, UserPlusIcon } from '../../components/icons';
import { StatCard } from '../stats';
import { DonutChart } from './DonutChart';
@@ -29,6 +30,7 @@ export function TrialsTab({ params }: TrialsTabProps) {
queryKey: ['sales-stats', 'trials', params],
queryFn: () => salesStatsApi.getTrials(params),
staleTime: SALES_STATS.STALE_TIME,
placeholderData: keepPreviousData,
});
if (isLoading) {
@@ -59,20 +61,24 @@ export function TrialsTab({ params }: TrialsTabProps) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label={t('admin.salesStats.trials.totalRegistrations')}
value={data.total_registrations}
icon={<UserPlusIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.salesStats.trials.total')}
value={data.total_trials}
icon={<GiftIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard label={t('admin.salesStats.trials.total')} value={data.total_trials} />
<StatCard
label={t('admin.salesStats.trials.conversion')}
value={`${data.conversion_rate}%`}
valueClassName="text-success-400"
/>
<StatCard
label={t('admin.salesStats.trials.avgDuration')}
value={`${data.avg_trial_duration_days} ${t('admin.trafficUsage.days')}`}
icon={<PercentIcon className="h-5 w-5" />}
tone="success"
/>
</div>

View File

@@ -3,6 +3,7 @@ export { DepositsTab } from './DepositsTab';
export { DonutChart } from './DonutChart';
export { DualAreaChart } from './DualAreaChart';
export { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
export { PaymentHealthTab } from './PaymentHealthTab';
export { PeriodSelector } from './PeriodSelector';
export { RenewalsTab } from './RenewalsTab';
export { SalesTab } from './SalesTab';

View File

@@ -1,29 +1,67 @@
import { type ReactNode } from 'react';
const DEFAULT_VALUE_CLASS = 'text-dark-100';
import { TREND_STYLES } from './constants';
export interface StatCardDelta {
/** Signed percent change vs the comparison period. */
percent: number;
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 {
label: string;
value: string | number;
icon?: ReactNode;
/** Tints the icon chip and (unless valueClassName is set) the value colour. */
tone?: keyof typeof TONE;
valueClassName?: string;
/** Optional period-over-period change shown under the value. */
delta?: StatCardDelta | null;
}
export function StatCard({
label,
value,
icon,
valueClassName = DEFAULT_VALUE_CLASS,
tone = 'neutral',
valueClassName,
delta,
}: StatCardProps) {
const toneStyle = TONE[tone];
const valueClass = valueClassName ?? toneStyle.value;
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">
<div className="rounded-xl bg-dark-800/30 p-3 transition-colors hover:bg-dark-800/50">
<div className="truncate text-xs text-dark-500 sm:text-sm">{label}</div>
{/* Chip is centred against the value line only (delta sits below the whole
row), so the icon lands in the same spot on every card. */}
<div className="mt-1.5 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}
<span>{label}</span>
</div>
<div className={`mt-1 truncate text-base font-semibold sm:text-lg ${valueClassName}`}>
</span>
)}
<div className={`min-w-0 flex-1 truncate text-lg font-semibold sm:text-xl ${valueClass}`}>
{value}
</div>
</div>
{trendStyle && (
<div className={`mt-1.5 text-xs font-medium ${trendStyle.className}`}>
{trendStyle.arrow} {Math.abs(delta!.percent)}%
</div>
)}
</div>
);
}

View File

@@ -18,6 +18,7 @@ export const METHOD_LABELS: Record<string, string> = {
paypear: 'PayPear',
rollypay: 'RollyPay',
aurapay: 'AuraPay',
overpay: 'OverPay',
etoplatezhi: 'Etoplatezhi',
antilopay: 'Antilopay',
jupiter: 'Jupiter',

View File

@@ -1282,6 +1282,9 @@
"title": "Sales Statistics",
"subtitle": "Revenue, trials, subscriptions, and renewals",
"period": {
"thisMonth": "This month",
"from": "From",
"to": "To",
"week": "7 days",
"month": "30 days",
"quarter": "90 days",
@@ -1293,13 +1296,15 @@
"sales": "Sales",
"renewals": "Renewals",
"addons": "Add-ons",
"deposits": "Deposits"
"deposits": "Deposits",
"payment": "Payments"
},
"summary": {
"revenue": "Revenue",
"activeSubs": "Active Subs",
"activeTrials": "Active Trials",
"newTrials": "New Trials",
"newPaid": "New Paid Subscriptions",
"conversion": "Conversion",
"renewals": "Renewals",
"addonRevenue": "Add-on Revenue",
@@ -1310,13 +1315,13 @@
"totalRegistrations": "Registrations",
"trialsIssued": "Trials Issued",
"conversion": "Conversion Rate",
"avgDuration": "Avg Duration",
"byProvider": "By Registration Source",
"dailyChart": "Daily Registrations & Trials",
"registrations": "Registrations"
},
"sales": {
"totalSales": "Total Sales",
"totalRevenue": "Subscription turnover",
"avgOrder": "Avg Order",
"topTariff": "Top Tariff",
"byTariff": "Sales by Tariff",
@@ -1356,6 +1361,12 @@
"dailyByMethod": "Daily Deposits by Payment Method",
"revenue": "Revenue"
},
"payments": {
"successRate": "Payment success rate",
"attempts": "Payment attempts",
"failedPurchases": "Failed purchases",
"byGateway": "Success rate by gateway"
},
"loadError": "Failed to load statistics"
},
"referralNetwork": {

View File

@@ -1155,6 +1155,9 @@
"title": "آمار فروش",
"subtitle": "درآمد، آزمایشی، اشتراک و تمدید",
"period": {
"thisMonth": "این ماه",
"from": "از",
"to": "تا",
"week": "۷ روز",
"month": "۳۰ روز",
"quarter": "۹۰ روز",
@@ -1166,13 +1169,15 @@
"sales": "فروش",
"renewals": "تمدید",
"addons": "خدمات اضافی",
"deposits": "شارژ"
"deposits": "شارژ",
"payment": "پرداخت‌ها"
},
"summary": {
"revenue": "درآمد",
"activeSubs": "اشتراک فعال",
"activeTrials": "آزمایشی فعال",
"newTrials": "آزمایشی جدید",
"newPaid": "اشتراک‌های پولی جدید",
"conversion": "تبدیل",
"renewals": "تمدید",
"addonRevenue": "درآمد خدمات اضافی",
@@ -1183,13 +1188,13 @@
"totalRegistrations": "ثبت‌نام‌ها",
"trialsIssued": "آزمایشی صادرشده",
"conversion": "نرخ تبدیل",
"avgDuration": "میانگین مدت",
"byProvider": "بر اساس منبع",
"dailyChart": "ثبت‌نام و آزمایشی روزانه",
"registrations": "ثبت‌نام"
},
"sales": {
"totalSales": "کل فروش",
"totalRevenue": "گردش اشتراک",
"avgOrder": "میانگین سفارش",
"topTariff": "تعرفه برتر",
"byTariff": "بر اساس تعرفه",
@@ -1229,6 +1234,12 @@
"dailyByMethod": "شارژ روزانه بر اساس روش پرداخت",
"revenue": "درآمد"
},
"payments": {
"successRate": "نرخ موفقیت پرداخت",
"attempts": "تلاش‌های پرداخت",
"failedPurchases": "خریدهای ناموفق",
"byGateway": "نرخ موفقیت بر اساس درگاه"
},
"loadError": "بارگذاری آمار ناموفق بود"
},
"referralNetwork": {

View File

@@ -1304,6 +1304,9 @@
"title": "Статистика продаж",
"subtitle": "Доход, триалы, подписки и продления",
"period": {
"thisMonth": "Этот месяц",
"from": "Начало",
"to": "Конец",
"week": "7 дней",
"month": "30 дней",
"quarter": "90 дней",
@@ -1315,13 +1318,15 @@
"sales": "Продажи",
"renewals": "Продления",
"addons": "Доп. услуги",
"deposits": "Пополнения"
"deposits": "Пополнения",
"payment": "Оплаты"
},
"summary": {
"revenue": "Доход",
"activeSubs": "Активные подписки",
"activeTrials": "Активные триалы",
"newTrials": "Новые триалы",
"newPaid": "Новые платные подписки",
"conversion": "Конверсия",
"renewals": "Продления",
"addonRevenue": "Доп. услуги",
@@ -1332,13 +1337,13 @@
"totalRegistrations": "Регистрации",
"trialsIssued": "Выданные триалы",
"conversion": "Конверсия",
"avgDuration": "Средняя длит.",
"byProvider": "По источнику регистрации",
"dailyChart": "Регистрации и триалы по дням",
"registrations": "Регистрации"
},
"sales": {
"totalSales": "Всего продаж",
"totalRevenue": "Оборот по подпискам",
"avgOrder": "Средний чек",
"topTariff": "Топ тариф",
"byTariff": "Продажи по тарифам",
@@ -1378,6 +1383,12 @@
"dailyByMethod": "Пополнения по дням по платёжкам",
"revenue": "Доход"
},
"payments": {
"successRate": "Успешность оплат",
"attempts": "Попыток оплат",
"failedPurchases": "Неудачные покупки",
"byGateway": "Успешность по шлюзам"
},
"loadError": "Не удалось загрузить статистику"
},
"referralNetwork": {

View File

@@ -1155,6 +1155,9 @@
"title": "销售统计",
"subtitle": "收入、试用、订阅和续订",
"period": {
"thisMonth": "本月",
"from": "从",
"to": "到",
"week": "7天",
"month": "30天",
"quarter": "90天",
@@ -1166,13 +1169,15 @@
"sales": "销售",
"renewals": "续订",
"addons": "附加服务",
"deposits": "充值"
"deposits": "充值",
"payment": "支付"
},
"summary": {
"revenue": "收入",
"activeSubs": "活跃订阅",
"activeTrials": "活跃试用",
"newTrials": "新试用",
"newPaid": "新增付费订阅",
"conversion": "转化率",
"renewals": "续订",
"addonRevenue": "附加服务收入",
@@ -1183,13 +1188,13 @@
"totalRegistrations": "注册数",
"trialsIssued": "已发放试用",
"conversion": "转化率",
"avgDuration": "平均时长",
"byProvider": "按注册来源",
"dailyChart": "每日注册与试用",
"registrations": "注册"
},
"sales": {
"totalSales": "总销售",
"totalRevenue": "订阅流水",
"avgOrder": "平均订单",
"topTariff": "热门套餐",
"byTariff": "按套餐",
@@ -1229,6 +1234,12 @@
"dailyByMethod": "每日按支付方式充值",
"revenue": "收入"
},
"payments": {
"successRate": "支付成功率",
"attempts": "支付尝试",
"failedPurchases": "失败购买",
"byGateway": "各网关成功率"
},
"loadError": "加载统计失败"
},
"referralNetwork": {

View File

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { rbacApi, AuditLogEntry, AuditLogFilters } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate';
import { DateField } from '@/components/DateField';
import { usePlatform } from '@/platform/hooks/usePlatform';
import {
BackIcon,
@@ -704,12 +705,11 @@ export default function AdminAuditLog() {
>
{t('admin.auditLog.filters.dateFrom')}
</label>
<input
id="filter-date-from"
type="date"
<DateField
value={filters.dateFrom}
onChange={(e) => setFilters((prev) => ({ ...prev, dateFrom: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
max={filters.dateTo}
onChange={(v) => setFilters((prev) => ({ ...prev, dateFrom: v }))}
className="flex w-full items-center gap-2 rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 transition-colors hover:border-accent-500"
/>
</div>
@@ -721,12 +721,11 @@ export default function AdminAuditLog() {
>
{t('admin.auditLog.filters.dateTo')}
</label>
<input
id="filter-date-to"
type="date"
<DateField
value={filters.dateTo}
onChange={(e) => setFilters((prev) => ({ ...prev, dateTo: e.target.value }))}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500"
min={filters.dateFrom}
onChange={(v) => setFilters((prev) => ({ ...prev, dateTo: v }))}
className="flex w-full items-center gap-2 rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-sm text-dark-100 transition-colors hover:border-accent-500"
/>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminPaymentsApi, type SearchStats } from '../api/adminPayments';
import { DateField } from '../components/DateField';
import { useCurrency } from '../hooks/useCurrency';
import type { PendingPayment, PaginatedResponse } from '../types';
import { usePlatform } from '../platform/hooks/usePlatform';
@@ -327,20 +328,20 @@ export default function AdminPayments() {
<label className="mb-1 block text-xs text-dark-400">
{t('admin.payments.dateFrom')}
</label>
<input
type="date"
<DateField
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none"
max={dateTo}
onChange={setDateFrom}
className="flex w-full items-center gap-2 rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 transition-colors hover:border-accent-500"
/>
</div>
<div className="flex-1">
<label className="mb-1 block text-xs text-dark-400">{t('admin.payments.dateTo')}</label>
<input
type="date"
<DateField
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none"
min={dateFrom}
onChange={setDateTo}
className="flex w-full items-center gap-2 rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 transition-colors hover:border-accent-500"
/>
</div>
<button onClick={() => refetch()} className="btn-primary px-4 py-2 text-sm">

View File

@@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { DateField } from '../components/DateField';
import { createNumberInputHandler } from '../utils/inputHelpers';
import {
promocodesApi,
@@ -447,12 +448,10 @@ export default function AdminPromocodeCreate() {
<label htmlFor="pc-valid-until" className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.promocodes.form.validUntil')}
</label>
<input
id="pc-valid-until"
type="date"
<DateField
value={validUntil}
onChange={(e) => setValidUntil(e.target.value)}
className="input"
onChange={setValidUntil}
className="flex w-full items-center gap-2 rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-100 transition-colors hover:border-accent-500/50"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.promocodes.form.validUntilHint')}</p>
</div>

View File

@@ -116,24 +116,33 @@ interface StatCardProps {
subValue?: string;
}
function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardProps) {
const colorClasses: Record<string, string> = {
accent: 'bg-accent-500/20 text-accent-400',
green: 'bg-success-500/20 text-success-400',
blue: 'bg-accent-500/20 text-accent-400',
orange: 'bg-warning-500/20 text-warning-400',
red: 'bg-error-500/20 text-error-400',
purple: 'bg-accent-500/20 text-accent-400',
// Soulful chip + matching value colour, in the spirit of the sales-stats cards.
// Fixed-size chip + forced icon size normalises every icon regardless of what the
// call site passes (some omit a className) — that was the source of the misalignment.
const STAT_TONE: Record<string, { chip: string; value: string }> = {
accent: { chip: 'bg-accent-500/15 text-accent-400', value: 'text-accent-400' },
green: { chip: 'bg-success-500/15 text-success-400', value: 'text-success-400' },
blue: { chip: 'bg-accent-500/15 text-accent-400', value: 'text-accent-400' },
orange: { chip: 'bg-warning-500/15 text-warning-400', value: 'text-warning-400' },
red: { chip: 'bg-error-500/15 text-error-400', value: 'text-error-400' },
purple: { chip: 'bg-accent-500/15 text-accent-400', value: 'text-accent-400' },
};
function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardProps) {
const tone = STAT_TONE[color] ?? STAT_TONE.accent;
return (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${colorClasses[color]}`}>{icon}</div>
<p className="truncate text-xs text-dark-400 sm:text-sm">{label}</p>
<div className="mt-1.5 flex items-center gap-2.5">
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg [&>svg]:h-5 [&>svg]:w-5 ${tone.chip}`}
>
{icon}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-dark-400">{label}</p>
<p className="text-lg font-semibold text-dark-100">{value}</p>
{subValue && <p className="text-xs text-dark-500">{subValue}</p>}
<div className={`truncate text-lg font-semibold sm:text-xl ${tone.value}`}>{value}</div>
{subValue && <div className="truncate text-xs text-dark-500">{subValue}</div>}
</div>
</div>
</div>

View File

@@ -1,23 +1,70 @@
import { useMemo, useState } from 'react';
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 { salesStatsApi } from '../api/adminSalesStats';
import { SALES_STATS } from '../constants/salesStats';
import { useCurrency } from '../hooks/useCurrency';
import { getMonthToDateRange } from '../utils/period';
import { AdminBackButton } from '../components/admin/AdminBackButton';
import {
BanknotesIcon,
GiftIcon,
PercentIcon,
PlusIcon,
RepeatIcon,
RocketIcon,
SparklesIcon,
TicketIcon,
WalletIcon,
} from '../components/icons';
import { StatCard } from '../components/stats';
import {
AddonsTab,
DepositsTab,
PaymentHealthTab,
PeriodSelector,
RenewalsTab,
SalesTab,
TrialsTab,
} from '../components/sales-stats';
type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits';
type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits' | 'payment';
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();
@@ -28,7 +75,7 @@ export default function AdminSalesStats() {
days?: number;
startDate?: string;
endDate?: string;
}>({ days: SALES_STATS.DEFAULT_PERIOD });
}>(() => getMonthToDateRange());
const params: SalesStatsParams = useMemo(
() => ({
@@ -50,14 +97,50 @@ export default function AdminSalesStats() {
queryFn: () => salesStatsApi.getSummary(params),
staleTime: SALES_STATS.STALE_TIME,
enabled: isValidPeriod,
placeholderData: keepPreviousData,
});
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,
placeholderData: keepPreviousData,
});
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),
newPaid: computeDelta(summary.new_paid_subscriptions, prevSummary.new_paid_subscriptions),
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]);
// Active-subscriptions trend = net change over the period (new paid expired),
// shown relative to the count at the start of the period. The active count
// itself is a "right now" snapshot, so a plain period-over-period delta would
// always be zero — this shows real growth/shrinkage instead.
const activeDelta = useMemo<Delta | null>(() => {
if (!summary) return null;
const net = summary.new_paid_subscriptions - summary.expired_subscriptions;
if (net === 0) return null;
const base = summary.active_subscriptions - net;
const percent = base > 0 ? Math.round((Math.abs(net) / base) * 1000) / 10 : 100;
return { percent, trend: net > 0 ? 'up' : 'down' };
}, [summary]);
const tabs: { id: TabId; label: string }[] = [
{ id: 'trials', label: t('admin.salesStats.tabs.trials') },
{ id: 'sales', label: t('admin.salesStats.tabs.sales') },
{ id: 'renewals', label: t('admin.salesStats.tabs.renewals') },
{ id: 'addons', label: t('admin.salesStats.tabs.addons') },
{ id: 'deposits', label: t('admin.salesStats.tabs.deposits') },
{ id: 'payment', label: t('admin.salesStats.tabs.payment') },
];
return (
@@ -82,7 +165,7 @@ export default function AdminSalesStats() {
{t('admin.salesStats.loadError')}
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 max-sm:[&>*:last-child:nth-child(odd)]:col-span-2">
<StatCard
label={t('admin.salesStats.summary.revenue')}
value={
@@ -90,33 +173,52 @@ export default function AdminSalesStats() {
? '...'
: formatWithCurrency(
(summary?.total_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
0,
)
}
valueClassName="text-success-400"
icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
delta={deltas?.revenue}
/>
<StatCard
label={t('admin.salesStats.summary.activeSubs')}
value={summaryLoading ? '...' : (summary?.active_subscriptions ?? 0)}
valueClassName="text-accent-400"
icon={<TicketIcon className="h-5 w-5" />}
tone="accent"
delta={activeDelta}
/>
<StatCard
label={t('admin.salesStats.summary.newPaid')}
value={summaryLoading ? '...' : (summary?.new_paid_subscriptions ?? 0)}
icon={<RocketIcon className="h-5 w-5" />}
tone="success"
delta={deltas?.newPaid}
/>
<StatCard
label={t('admin.salesStats.summary.activeTrials')}
value={summaryLoading ? '...' : (summary?.active_trials ?? 0)}
icon={<GiftIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.salesStats.summary.newTrials')}
value={summaryLoading ? '...' : (summary?.new_trials ?? 0)}
valueClassName="text-accent-400"
icon={<SparklesIcon className="h-5 w-5" />}
tone="accent"
delta={deltas?.newTrials}
/>
<StatCard
label={t('admin.salesStats.summary.conversion')}
value={summaryLoading ? '...' : `${summary?.trial_to_paid_conversion ?? 0}%`}
valueClassName="text-warning-400"
icon={<PercentIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.salesStats.summary.renewals')}
value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)}
valueClassName="text-success-400"
icon={<RepeatIcon className="h-5 w-5" />}
tone="success"
delta={deltas?.renewals}
/>
<StatCard
label={t('admin.salesStats.summary.addonRevenue')}
@@ -125,18 +227,26 @@ export default function AdminSalesStats() {
? '...'
: formatWithCurrency(
(summary?.addon_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
0,
)
}
valueClassName="text-accent-400"
icon={<PlusIcon className="h-5 w-5" />}
tone="accent"
delta={deltas?.addonRevenue}
/>
<StatCard
label={t('admin.salesStats.summary.manualTopup')}
value={
summaryLoading
? '...'
: formatWithCurrency((summary?.manual_topup_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR)
: formatWithCurrency(
(summary?.manual_topup_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
0,
)
}
valueClassName="text-warning-400"
icon={<WalletIcon className="h-5 w-5" />}
tone="warning"
delta={deltas?.manualTopup}
/>
</div>
@@ -173,6 +283,7 @@ export default function AdminSalesStats() {
{activeTab === 'renewals' && <RenewalsTab params={params} />}
{activeTab === 'addons' && <AddonsTab params={params} />}
{activeTab === 'deposits' && <DepositsTab params={params} />}
{activeTab === 'payment' && <PaymentHealthTab params={params} />}
</div>
)}
</div>

View File

@@ -17,6 +17,7 @@ type Status = 'countdown' | 'fallback' | 'error';
// App schemes configuration - same as miniapp
const appSchemes = [
{ scheme: 'happ://', icon: 'H', name: 'Happ' },
{ scheme: 'incy://', icon: 'I', name: 'INCY' },
{ scheme: 'flclash://', icon: 'F', name: 'FlClash' },
{ scheme: 'clash://', icon: 'C', name: 'Clash Meta' },
{ scheme: 'sing-box://', icon: 'S', name: 'sing-box' },

View File

@@ -1380,6 +1380,89 @@ input[type='checkbox']:hover:not(:checked) {
background-color: rgb(var(--color-dark-600));
}
/* Recharts: bars, pie sectors and dots become focusable, so clicking one draws
the browser's focus outline — a visual artifact on read-only charts. Kill it. */
.recharts-wrapper:focus,
.recharts-wrapper:focus-visible,
.recharts-wrapper :focus,
.recharts-wrapper :focus-visible,
.recharts-surface,
.recharts-sector,
.recharts-rectangle {
outline: none;
}
/* react-day-picker (DateField) — themed via the library's CSS variables.
The library declares its rdp variables ON .rdp-root, so overriding them on a
parent (.rdp-dark) gets shadowed. We must target `.rdp-dark .rdp-root` to win.
All colors go through the app's theme tokens (the dark and accent scales),
which flip for light themes (e.g. champagne) — so the picker stays legible in
every theme without bespoke per-theme rules. */
.rdp-dark {
color: rgb(var(--color-dark-100));
font-size: 0.85rem;
}
.rdp-dark .rdp-root {
--rdp-accent-color: rgb(var(--color-accent-500));
--rdp-accent-background-color: rgb(var(--color-accent-500) / 0.18);
--rdp-today-color: rgb(var(--color-accent-400));
--rdp-day-height: 2.25rem;
--rdp-day-width: 2.25rem;
--rdp-outside-opacity: 0.4;
--rdp-disabled-opacity: 0.4;
--rdp-selected-border: none;
--rdp-weekday-opacity: 1;
}
.rdp-dark .rdp-month_caption,
.rdp-dark .rdp-caption_label {
color: rgb(var(--color-dark-100));
font-weight: 600;
text-transform: capitalize;
}
.rdp-dark .rdp-weekday {
color: rgb(var(--color-dark-400));
font-weight: 500;
text-transform: capitalize;
}
.rdp-dark .rdp-day_button {
color: rgb(var(--color-dark-100));
border: none;
border-radius: 0.5rem;
}
.rdp-dark .rdp-day_button:hover:not([disabled]) {
background-color: rgb(var(--color-dark-700));
}
.rdp-dark .rdp-day_button:focus-visible {
outline: 2px solid rgb(var(--color-accent-500));
outline-offset: 1px;
}
/* Today: accent-tinted text + subtle ring (loses to the filled "selected" look). */
.rdp-dark .rdp-today:not(.rdp-outside) .rdp-day_button {
color: rgb(var(--color-accent-400));
font-weight: 700;
box-shadow: inset 0 0 0 1px rgb(var(--color-accent-500) / 0.45);
}
.rdp-dark .rdp-selected .rdp-day_button {
background-color: rgb(var(--color-accent-500));
color: #fff;
font-weight: 700;
border: none;
box-shadow: none;
}
.rdp-dark .rdp-chevron {
fill: currentColor;
}
.rdp-dark .rdp-button_previous,
.rdp-dark .rdp-button_next {
color: rgb(var(--color-dark-300));
border-radius: 0.5rem;
}
.rdp-dark .rdp-button_previous:hover:not([disabled]),
.rdp-dark .rdp-button_next:hover:not([disabled]) {
color: rgb(var(--color-dark-100));
background-color: rgb(var(--color-dark-700));
}
.light input[type='checkbox'] {
border-color: rgb(var(--color-champagne-400));
background-color: rgb(var(--color-champagne-100));

22
src/utils/period.ts Normal file
View File

@@ -0,0 +1,22 @@
/** First day of the current month → today, as local YYYY-MM-DD strings.
* Used as the default sales-stats window ("this month so far"). */
export function getMonthToDateRange(): { startDate: string; endDate: string } {
const now = new Date();
const toLocalISODate = (d: Date) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return {
startDate: toLocalISODate(new Date(now.getFullYear(), now.getMonth(), 1)),
endDate: toLocalISODate(now),
};
}
/** True when the period is exactly "from the 1st of the current month to today". */
export function isMonthToDate(period: {
days?: number;
startDate?: string;
endDate?: string;
}): boolean {
if (period.days !== undefined) return false;
const mtd = getMonthToDateRange();
return period.startDate === mtd.startDate && period.endDate === mtd.endDate;
}