mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge pull request #447 from BEDOLAGA-DEV/dev
Релиз: переработка статистики продаж, новый датапикер, INCY
This commit is contained in:
43
package-lock.json
generated
43
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
113
src/components/DateField.tsx
Normal file
113
src/components/DateField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
80
src/components/sales-stats/BreakdownList.tsx
Normal file
80
src/components/sales-stats/BreakdownList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
115
src/components/sales-stats/PaymentHealthTab.tsx
Normal file
115
src/components/sales-stats/PaymentHealthTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
135
src/components/sales-stats/StackedBarChart.tsx
Normal file
135
src/components/sales-stats/StackedBarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
22
src/utils/period.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user