refactor(cabinet): unify all statistics onto the shared StatCard

Bring every stat strip across the admin panel and user cabinet onto the
canonical StatCard (icon chip + label-on-top + tone-coloured value),
matching the sales/Remnawave style the rest of the app already used.

- Convert ~20 hand-rolled stat strips across 21 pages/components
- Remove 4 divergent local StatCard components (Dashboard, Users,
  BanSystem, Payments) and migrate Remnawave's local StatCard (33 cards)
- Extend StatCard with optional subValue, loading skeleton, trailing slot,
  and icon-size normalisation (all backward-compatible)
- StatsGrid nav tiles now use StatCard while keeping link/chevron/loading
- Preserve all data, i18n keys, conditionals and interactive filters;
  fix the dead 'info' colour by mapping it to accent
This commit is contained in:
c0mrade
2026-06-06 21:27:57 +03:00
parent 9fc681b56d
commit 3b48abbb7b
23 changed files with 863 additions and 973 deletions

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { SendIcon } from '@/components/icons';
import { GiftIcon, SendIcon } from '@/components/icons';
import { StatCard } from '@/components/stats';
import { useCurrency } from '../../../hooks/useCurrency';
import type { AdminUserGiftItem, AdminUserGiftsResponse } from '../../../api/adminUsers';
@@ -208,18 +209,18 @@ export function GiftsTab({ giftsLoading, giftsData, locale, onNavigateToUser }:
<div className="space-y-6">
{/* Summary counters */}
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.gifts.totalSent')}
</div>
<div className="text-2xl font-bold text-accent-400">{giftsData.sent_total}</div>
</div>
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('admin.users.detail.gifts.totalReceived')}
</div>
<div className="text-2xl font-bold text-success-400">{giftsData.received_total}</div>
</div>
<StatCard
label={t('admin.users.detail.gifts.totalSent')}
value={giftsData.sent_total}
icon={<GiftIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.users.detail.gifts.totalReceived')}
value={giftsData.received_total}
icon={<GiftIcon className="h-5 w-5" />}
tone="success"
/>
</div>
{/* Sent Gifts */}

View File

@@ -5,7 +5,8 @@ import { useNavigate } from 'react-router';
import { useCurrency } from '../../../hooks/useCurrency';
import { useNotify } from '../../../platform/hooks/useNotify';
import { adminUsersApi, type UserDetailResponse, type UserListItem } from '../../../api/adminUsers';
import { XIcon } from '@/components/icons';
import { StatCard } from '@/components/stats';
import { BanknotesIcon, PercentIcon, TagIcon, UsersIcon, XIcon } from '@/components/icons';
// ──────────────────────────────────────────────────────────────────
// Referrals tab — top-of-graph referrer + stats + referrals list,
@@ -327,40 +328,35 @@ export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps)
{/* Section 2: Referral stats */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.totalReferrals')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{user.referral.referrals_count}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.totalEarnings')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.commission')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{user.referral.commission_percent != null
<StatCard
label={t('admin.users.detail.referrals.totalReferrals')}
value={user.referral.referrals_count}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.users.detail.referrals.totalEarnings')}
value={formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.users.detail.referrals.commission')}
value={
user.referral.commission_percent != null
? `${user.referral.commission_percent}%`
: t('admin.users.detail.referrals.default')}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.referralCode')}
</div>
<div className="mt-1 truncate font-mono text-sm text-dark-100">
{user.referral.referral_code}
</div>
</div>
: t('admin.users.detail.referrals.default')
}
icon={<PercentIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.users.detail.referrals.referralCode')}
value={user.referral.referral_code}
icon={<TagIcon className="h-5 w-5" />}
tone="neutral"
valueClassName="font-mono"
/>
</div>
{/* Section 3: Referrals list */}

View File

@@ -1,9 +1,8 @@
import { PiCaretRight } from 'react-icons/pi';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { useCurrency } from '../../hooks/useCurrency';
import { useTheme } from '../../hooks/useTheme';
import { getGlassColors } from '../../utils/glassTheme';
import { StatCard } from '@/components/stats';
import { CardIcon, ChevronRightIcon, UsersIcon } from '@/components/icons';
interface StatsGridProps {
balanceRubles: number;
@@ -12,10 +11,6 @@ interface StatsGridProps {
refLoading: boolean;
}
const ChevronIcon = ({ color }: { color: string }) => (
<PiCaretRight width={16} height={16} style={{ flexShrink: 0, color }} aria-hidden="true" />
);
export default function StatsGrid({
balanceRubles,
referralCount,
@@ -24,122 +19,31 @@ export default function StatsGrid({
}: StatsGridProps) {
const { t } = useTranslation();
const { formatAmount, currencySymbol } = useCurrency();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const accentColor = 'rgb(var(--color-accent-400))';
const accentBg = 'rgba(var(--color-accent-400), 0.07)';
const cards = [
{
label: t('dashboard.stats.balance'),
value: `${formatAmount(balanceRubles)} ${currencySymbol}`,
valueColor: accentColor,
to: '/balance',
icon: (color: string) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="6" width="20" height="14" rx="2" />
<path d="M2 10h20" />
<path d="M6 14h.01M10 14h.01" />
</svg>
),
iconBg: accentBg,
iconColor: accentColor,
loading: false,
onboarding: 'balance',
},
{
label: t('dashboard.stats.referrals'),
value: `${referralCount}`,
valueColor: g.text,
subtitle: `+${formatAmount(earningsRubles)} ${currencySymbol}`,
subtitleColor: accentColor,
to: '/referral',
icon: (color: string) => (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
),
iconBg: g.trackBg,
iconColor: g.textSecondary,
loading: refLoading,
},
];
const chevron = <ChevronRightIcon className="h-4 w-4 shrink-0 text-dark-500" />;
return (
<div className="grid grid-cols-2 gap-2.5">
{cards.map((card, i) => (
<Link
key={i}
to={card.to}
className="group relative overflow-hidden rounded-[18px] transition-all duration-200"
style={{
background: g.cardBg,
border: `1px solid ${g.cardBorder}`,
boxShadow: g.shadow,
padding: '18px 20px 20px',
}}
data-onboarding={card.onboarding}
>
{/* Top row: icon + label + arrow */}
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-[9px] transition-colors duration-500"
style={{ background: card.iconBg }}
>
{card.icon(card.iconColor)}
</div>
<span className="text-[13px] font-medium text-dark-50/45">{card.label}</span>
</div>
<ChevronIcon color={g.textFaint} />
</div>
{/* Value */}
{card.loading ? (
<div className="skeleton h-8 w-20" />
) : (
<>
<div
className="text-[28px] font-bold leading-tight tracking-tight transition-colors duration-500"
style={{ color: card.valueColor }}
>
{card.value}
</div>
{card.subtitle && (
<div
className="mt-0.5 text-[13px] font-semibold"
style={{ color: card.subtitleColor }}
>
{card.subtitle}
</div>
)}
</>
)}
</Link>
))}
<Link to="/balance" className="block" data-onboarding="balance">
<StatCard
label={t('dashboard.stats.balance')}
value={`${formatAmount(balanceRubles)} ${currencySymbol}`}
icon={<CardIcon className="h-5 w-5" />}
tone="accent"
trailing={chevron}
/>
</Link>
<Link to="/referral" className="block">
<StatCard
label={t('dashboard.stats.referrals')}
value={`${referralCount}`}
subValue={`+${formatAmount(earningsRubles)} ${currencySymbol}`}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
loading={refLoading}
trailing={chevron}
/>
</Link>
</div>
);
}

View File

@@ -24,6 +24,12 @@ interface StatCardProps {
/** Tints the icon chip and (unless valueClassName is set) the value colour. */
tone?: keyof typeof TONE;
valueClassName?: string;
/** Optional secondary line shown under the value (e.g. a subtitle or context). */
subValue?: string;
/** When true, shows a skeleton placeholder instead of the value. */
loading?: boolean;
/** Optional node rendered at the right edge of the label row (e.g. a chevron for nav cards). */
trailing?: ReactNode;
/** Optional period-over-period change shown under the value. */
delta?: StatCardDelta | null;
}
@@ -34,6 +40,9 @@ export function StatCard({
icon,
tone = 'neutral',
valueClassName,
subValue,
loading,
trailing,
delta,
}: StatCardProps) {
const toneStyle = TONE[tone];
@@ -42,19 +51,32 @@ export function StatCard({
return (
<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>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs text-dark-500 sm:text-sm">{label}</span>
{trailing}
</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. */}
row), so the icon lands in the same spot on every card. The forced svg
size normalises every icon regardless of what the call site passes. */}
<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}`}
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg [&>svg]:h-5 [&>svg]:w-5 ${toneStyle.chip}`}
>
{icon}
</span>
)}
<div className={`min-w-0 flex-1 truncate text-lg font-semibold sm:text-xl ${valueClass}`}>
{value}
<div className="min-w-0 flex-1">
{loading ? (
<div className="skeleton h-7 w-20 rounded" />
) : (
<>
<div className={`truncate text-lg font-semibold sm:text-xl ${valueClass}`}>
{value}
</div>
{subValue && <div className="truncate text-xs text-dark-500">{subValue}</div>}
</>
)}
</div>
</div>
{trendStyle && (

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { AdminBackButton } from '../components/admin/AdminBackButton';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { StatCard } from '@/components/stats';
import {
banSystemApi,
type BanSystemStatus,
@@ -37,6 +38,8 @@ import {
ExclamationIcon,
BackIcon,
XIcon,
ClockIcon,
StatusIcon,
} from '@/components/icons';
type TabType =
@@ -51,35 +54,6 @@ type TabType =
| 'reports'
| 'health';
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: React.ReactNode;
color: 'accent' | 'success' | 'warning' | 'error' | 'info';
}
function StatCard({ title, value, subtitle, icon, color }: StatCardProps) {
const colorClasses = {
accent: 'bg-accent-500/20 text-accent-400',
success: 'bg-success-500/20 text-success-400',
warning: 'bg-warning-500/20 text-warning-400',
error: 'bg-error-500/20 text-error-400',
info: 'bg-info-500/20 text-info-400',
};
return (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 backdrop-blur transition-colors hover:border-dark-600">
<div className="mb-2 flex items-start justify-between">
<div className={`rounded-lg p-2 ${colorClasses[color]}`}>{icon}</div>
</div>
<div className="mb-1 text-2xl font-bold text-dark-100">{value}</div>
<div className="text-sm text-dark-400">{title}</div>
{subtitle && <div className="mt-1 text-xs text-dark-500">{subtitle}</div>}
</div>
);
}
export default function AdminBanSystem() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<TabType>('dashboard');
@@ -504,58 +478,58 @@ export default function AdminBanSystem() {
{activeTab === 'dashboard' && stats && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
title={t('banSystem.stats.activeUsers')}
label={t('banSystem.stats.activeUsers')}
value={stats.active_users}
subtitle={`${t('banSystem.stats.total')}: ${stats.total_users}`}
icon={<UsersIcon />}
color="success"
subValue={`${t('banSystem.stats.total')}: ${stats.total_users}`}
icon={<UsersIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
title={t('banSystem.stats.usersOverLimit')}
label={t('banSystem.stats.usersOverLimit')}
value={stats.users_over_limit}
icon={<WarningIcon />}
color="warning"
icon={<WarningIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
title={t('banSystem.stats.activeBans')}
label={t('banSystem.stats.activeBans')}
value={stats.active_punishments}
subtitle={`${t('banSystem.stats.total')}: ${stats.total_punishments}`}
icon={<BanIcon />}
color="error"
subValue={`${t('banSystem.stats.total')}: ${stats.total_punishments}`}
icon={<BanIcon className="h-5 w-5" />}
tone="error"
/>
<StatCard
title={t('banSystem.stats.nodesOnline')}
label={t('banSystem.stats.nodesOnline')}
value={`${stats.nodes_online}/${stats.nodes_total}`}
icon={<ServerIcon />}
color="accent"
icon={<ServerIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
title={t('banSystem.stats.agentsOnline')}
label={t('banSystem.stats.agentsOnline')}
value={`${stats.agents_online}/${stats.agents_total}`}
icon={<AgentIcon />}
color="info"
icon={<AgentIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
title={t('banSystem.stats.totalRequests')}
label={t('banSystem.stats.totalRequests')}
value={stats.total_requests.toLocaleString()}
icon={<ChartIcon className="h-5 w-5" />}
color="accent"
tone="accent"
/>
<StatCard
title={t('banSystem.stats.panelStatus')}
label={t('banSystem.stats.panelStatus')}
value={
stats.panel_connected
? t('banSystem.stats.connected')
: t('banSystem.stats.disconnected')
}
icon={<ServerIcon />}
color={stats.panel_connected ? 'success' : 'error'}
icon={<StatusIcon className="h-5 w-5" />}
tone={stats.panel_connected ? 'success' : 'error'}
/>
<StatCard
title={t('banSystem.stats.uptime')}
label={t('banSystem.stats.uptime')}
value={formatUptime(stats.uptime_seconds)}
icon={<ChartIcon className="h-5 w-5" />}
color="info"
icon={<ClockIcon className="h-5 w-5" />}
tone="accent"
/>
</div>
)}
@@ -777,29 +751,29 @@ export default function AdminBanSystem() {
{agents?.summary && (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
title={t('banSystem.agents.online')}
label={t('banSystem.agents.online')}
value={`${agents.summary.online_agents}/${agents.summary.total_agents}`}
icon={<AgentIcon />}
color="success"
icon={<AgentIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
title={t('banSystem.agents.totalSent')}
label={t('banSystem.agents.totalSent')}
value={agents.summary.total_sent.toLocaleString()}
icon={<ChartIcon className="h-5 w-5" />}
color="accent"
tone="accent"
/>
<StatCard
title={t('banSystem.agents.totalDropped')}
label={t('banSystem.agents.totalDropped')}
value={agents.summary.total_dropped.toLocaleString()}
icon={<WarningIcon />}
color="warning"
icon={<WarningIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
title={t('banSystem.agents.healthy')}
label={t('banSystem.agents.healthy')}
value={agents.summary.healthy_count}
subtitle={`${t('banSystem.agents.warning')}: ${agents.summary.warning_count}, ${t('banSystem.agents.critical')}: ${agents.summary.critical_count}`}
icon={<AgentIcon />}
color="info"
subValue={`${t('banSystem.agents.warning')}: ${agents.summary.warning_count}, ${t('banSystem.agents.critical')}: ${agents.summary.critical_count}`}
icon={<AgentIcon className="h-5 w-5" />}
tone="accent"
/>
</div>
)}
@@ -952,10 +926,10 @@ export default function AdminBanSystem() {
{/* Traffic Stats */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
title={t('banSystem.traffic.enabled')}
label={t('banSystem.traffic.enabled')}
value={traffic.enabled ? t('common.yes') : t('common.no')}
icon={<TrafficIcon />}
color={traffic.enabled ? 'success' : 'warning'}
icon={<TrafficIcon className="h-5 w-5" />}
tone={traffic.enabled ? 'success' : 'warning'}
/>
</div>
@@ -1086,16 +1060,16 @@ export default function AdminBanSystem() {
{/* Report Stats */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
title={t('banSystem.reports.currentUsers')}
label={t('banSystem.reports.currentUsers')}
value={report.current_users}
icon={<UsersIcon />}
color="accent"
icon={<UsersIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
title={t('banSystem.reports.currentIps')}
label={t('banSystem.reports.currentIps')}
value={report.current_ips}
icon={<ServerIcon />}
color="info"
icon={<ServerIcon className="h-5 w-5" />}
tone="accent"
/>
</div>

View File

@@ -3,14 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin';
import { StatCard } from '@/components/stats';
import {
BanIcon,
DocumentIcon,
EmailIcon,
PhotoIcon,
RefreshIcon,
SendIcon,
StopIcon,
TelegramIcon,
UsersIcon,
VideoIcon,
XCircleIcon,
} from '@/components/icons';
// Channel badge component
@@ -205,22 +210,30 @@ export default function AdminBroadcastDetail() {
{/* Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-center">
<p className="text-3xl font-bold text-dark-100">{broadcast.total_count}</p>
<p className="text-sm text-dark-400">{t('admin.broadcasts.total')}</p>
</div>
<div className="rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-center">
<p className="text-3xl font-bold text-success-400">{broadcast.sent_count}</p>
<p className="text-sm text-dark-400">{t('admin.broadcasts.sent')}</p>
</div>
<div className="rounded-xl border border-warning-500/30 bg-warning-500/10 p-4 text-center">
<p className="text-3xl font-bold text-warning-400">{broadcast.blocked_count}</p>
<p className="text-sm text-dark-400">{t('admin.broadcasts.blocked')}</p>
</div>
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-center">
<p className="text-3xl font-bold text-error-400">{broadcast.failed_count}</p>
<p className="text-sm text-dark-400">{t('admin.broadcasts.failed')}</p>
</div>
<StatCard
label={t('admin.broadcasts.total')}
value={broadcast.total_count}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.broadcasts.sent')}
value={broadcast.sent_count}
icon={<SendIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.broadcasts.blocked')}
value={broadcast.blocked_count}
icon={<BanIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.broadcasts.failed')}
value={broadcast.failed_count}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
</div>
{/* Target */}

View File

@@ -12,7 +12,12 @@ import {
XIcon,
ChartIcon,
BackIcon,
CampaignIcon,
BoltIcon,
UserPlusIcon,
BanknotesIcon,
} from '../components/icons';
import { StatCard } from '../components/stats';
import { usePlatform } from '../platform/hooks/usePlatform';
import { useFocusTrap } from '../hooks/useFocusTrap';
@@ -141,30 +146,30 @@ export default function AdminCampaigns() {
{/* Overview */}
{overview && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{overview.total}</div>
<div className="text-sm text-dark-400">
{t('admin.campaigns.overview.totalCampaigns')}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-success-400">{overview.active}</div>
<div className="text-sm text-dark-400">{t('admin.campaigns.overview.active')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-accent-400">{overview.total_registrations}</div>
<div className="text-sm text-dark-400">
{t('admin.campaigns.overview.registrations')}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-success-400">
{formatRubles(overview.total_balance_issued_kopeks)}
</div>
<div className="text-sm text-dark-400">
{t('admin.campaigns.overview.bonusesIssued')}
</div>
</div>
<StatCard
label={t('admin.campaigns.overview.totalCampaigns')}
value={overview.total}
icon={<CampaignIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.campaigns.overview.active')}
value={overview.active}
icon={<BoltIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.campaigns.overview.registrations')}
value={overview.total_registrations}
icon={<UserPlusIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.campaigns.overview.bonusesIssued')}
value={formatRubles(overview.total_balance_issued_kopeks)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
/>
</div>
)}

View File

@@ -9,11 +9,17 @@ const CABINET_VERSION = __APP_VERSION__;
import { useCurrency } from '../hooks/useCurrency';
import { usePlatform } from '../platform/hooks/usePlatform';
import { StatCard } from '@/components/stats';
import {
BackIcon,
BanknotesIcon,
CalendarBlankIcon,
CalendarIcon,
ChartBarIcon,
CheckCircleIcon,
ChevronDownIcon,
ClockIcon,
CreditCardIcon,
ExclamationIcon,
MegaphoneIcon,
PowerIcon,
@@ -21,53 +27,14 @@ import {
RestartIcon,
ServerIcon,
SparklesIcon,
StarIcon,
TagIcon,
UsersIcon,
UsersOnlineIcon,
WalletIcon,
XCircleIcon,
} from '@/components/icons';
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: React.ReactNode;
color: 'accent' | 'success' | 'warning' | 'error' | 'info';
trend?: {
value: number;
label: string;
};
}
function StatCard({ title, value, subtitle, icon, color, trend }: StatCardProps) {
const colorClasses = {
accent: 'bg-accent-500/20 text-accent-400',
success: 'bg-success-500/20 text-success-400',
warning: 'bg-warning-500/20 text-warning-400',
error: 'bg-error-500/20 text-error-400',
info: 'bg-info-500/20 text-info-400',
};
return (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-5 transition-colors hover:border-dark-600">
<div className="mb-3 flex items-start justify-between">
<div className={`rounded-lg p-2.5 ${colorClasses[color]}`}>{icon}</div>
{trend && (
<div
className={`rounded-full px-2 py-1 text-xs ${trend.value >= 0 ? 'bg-success-500/20 text-success-400' : 'bg-error-500/20 text-error-400'}`}
>
{trend.value >= 0 ? '+' : ''}
{trend.value}% {trend.label}
</div>
)}
</div>
<div className="mb-1 text-2xl font-bold text-dark-100">{value}</div>
<div className="text-sm text-dark-400">{title}</div>
{subtitle && <div className="mt-1 text-xs text-dark-500">{subtitle}</div>}
</div>
);
}
interface NodeCardProps {
node: NodeStatus;
onRestart: (uuid: string) => void;
@@ -343,29 +310,29 @@ export default function AdminDashboard() {
{/* Main Stats Grid */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
title={t('adminDashboard.stats.usersOnline')}
label={t('adminDashboard.stats.usersOnline')}
value={stats?.nodes.total_users_online || 0}
icon={<UsersOnlineIcon />}
color="success"
icon={<UsersOnlineIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
title={t('adminDashboard.stats.activeSubscriptions')}
label={t('adminDashboard.stats.activeSubscriptions')}
value={stats?.subscriptions.active || 0}
subtitle={`${t('adminDashboard.stats.total')}: ${stats?.subscriptions.total || 0}`}
icon={<SparklesIcon />}
color="accent"
subValue={`${t('adminDashboard.stats.total')}: ${stats?.subscriptions.total || 0}`}
icon={<SparklesIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
title={t('adminDashboard.stats.incomeToday')}
label={t('adminDashboard.stats.incomeToday')}
value={`${formatAmount(stats?.financial.income_today_rubles || 0)} ${currencySymbol}`}
icon={<WalletIcon />}
color="warning"
icon={<WalletIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
title={t('adminDashboard.stats.incomeMonth')}
label={t('adminDashboard.stats.incomeMonth')}
value={`${formatAmount(stats?.financial.income_month_rubles || 0)} ${currencySymbol}`}
icon={<ChartBarIcon />}
color="info"
icon={<ChartBarIcon className="h-5 w-5" />}
tone="accent"
/>
</div>
@@ -453,22 +420,18 @@ export default function AdminDashboard() {
</div>
<RevenueChart data={stats?.revenue_chart || []} />
<div className="mt-4 grid grid-cols-2 gap-4 border-t border-dark-700 pt-4">
<div>
<div className="mb-1 text-xs text-dark-500">
{t('adminDashboard.stats.incomeTotal')}
</div>
<div className="text-xl font-bold text-dark-100">
{formatAmount(stats?.financial.income_total_rubles || 0)} {currencySymbol}
</div>
</div>
<div>
<div className="mb-1 text-xs text-dark-500">
{t('adminDashboard.stats.subscriptionIncome')}
</div>
<div className="text-xl font-bold text-accent-400">
{formatAmount(stats?.financial.subscription_income_rubles || 0)} {currencySymbol}
</div>
</div>
<StatCard
label={t('adminDashboard.stats.incomeTotal')}
value={`${formatAmount(stats?.financial.income_total_rubles || 0)} ${currencySymbol}`}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('adminDashboard.stats.subscriptionIncome')}
value={`${formatAmount(stats?.financial.subscription_income_rubles || 0)} ${currencySymbol}`}
icon={<SparklesIcon className="h-5 w-5" />}
tone="accent"
/>
</div>
</div>
@@ -488,38 +451,30 @@ export default function AdminDashboard() {
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-dark-900/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('adminDashboard.subscriptions.active')}
</div>
<div className="text-2xl font-bold text-success-400">
{stats?.subscriptions.active || 0}
</div>
</div>
<div className="rounded-lg bg-dark-900/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('adminDashboard.subscriptions.trial')}
</div>
<div className="text-2xl font-bold text-warning-400">
{stats?.subscriptions.trial || 0}
</div>
</div>
<div className="rounded-lg bg-dark-900/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('adminDashboard.subscriptions.paid')}
</div>
<div className="text-2xl font-bold text-accent-400">
{stats?.subscriptions.paid || 0}
</div>
</div>
<div className="rounded-lg bg-dark-900/50 p-4">
<div className="mb-1 text-xs text-dark-500">
{t('adminDashboard.subscriptions.expired')}
</div>
<div className="text-2xl font-bold text-error-400">
{stats?.subscriptions.expired || 0}
</div>
</div>
<StatCard
label={t('adminDashboard.subscriptions.active')}
value={stats?.subscriptions.active || 0}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('adminDashboard.subscriptions.trial')}
value={stats?.subscriptions.trial || 0}
icon={<StarIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('adminDashboard.subscriptions.paid')}
value={stats?.subscriptions.paid || 0}
icon={<CreditCardIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('adminDashboard.subscriptions.expired')}
value={stats?.subscriptions.expired || 0}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
</div>
<div className="border-t border-dark-700 pt-4">
@@ -527,30 +482,24 @@ export default function AdminDashboard() {
{t('adminDashboard.subscriptions.newSubscriptions')}
</div>
<div className="grid grid-cols-3 gap-3">
<div className="text-center">
<div className="text-xl font-bold text-dark-100">
{stats?.subscriptions.purchased_today || 0}
</div>
<div className="text-xs text-dark-500">
{t('adminDashboard.subscriptions.today')}
</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-dark-100">
{stats?.subscriptions.purchased_week || 0}
</div>
<div className="text-xs text-dark-500">
{t('adminDashboard.subscriptions.week')}
</div>
</div>
<div className="text-center">
<div className="text-xl font-bold text-dark-100">
{stats?.subscriptions.purchased_month || 0}
</div>
<div className="text-xs text-dark-500">
{t('adminDashboard.subscriptions.month')}
</div>
</div>
<StatCard
label={t('adminDashboard.subscriptions.today')}
value={stats?.subscriptions.purchased_today || 0}
icon={<ClockIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('adminDashboard.subscriptions.week')}
value={stats?.subscriptions.purchased_week || 0}
icon={<CalendarBlankIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('adminDashboard.subscriptions.month')}
value={stats?.subscriptions.purchased_month || 0}
icon={<CalendarIcon className="h-5 w-5" />}
tone="neutral"
/>
</div>
</div>
@@ -741,48 +690,39 @@ export default function AdminDashboard() {
{/* Period Stats */}
<div className="mt-4 grid grid-cols-3 gap-2 border-t border-dark-700 pt-4 sm:gap-3">
<div className="text-center">
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">
{t('adminDashboard.period.today')}
</div>
<div className="truncate text-xs font-semibold text-dark-200 sm:text-base">
{formatAmount(
(referrersTab === 'earnings'
? referrers.by_earnings
: referrers.by_invited
).reduce((sum, r) => sum + r.earnings_today_kopeks, 0) / 100,
)}{' '}
{currencySymbol}
</div>
</div>
<div className="text-center">
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">
{t('adminDashboard.period.week')}
</div>
<div className="truncate text-xs font-semibold text-dark-200 sm:text-base">
{formatAmount(
(referrersTab === 'earnings'
? referrers.by_earnings
: referrers.by_invited
).reduce((sum, r) => sum + r.earnings_week_kopeks, 0) / 100,
)}{' '}
{currencySymbol}
</div>
</div>
<div className="text-center">
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">
{t('adminDashboard.period.month')}
</div>
<div className="truncate text-xs font-semibold text-dark-200 sm:text-base">
{formatAmount(
(referrersTab === 'earnings'
? referrers.by_earnings
: referrers.by_invited
).reduce((sum, r) => sum + r.earnings_month_kopeks, 0) / 100,
)}{' '}
{currencySymbol}
</div>
</div>
<StatCard
label={t('adminDashboard.period.today')}
value={`${formatAmount(
(referrersTab === 'earnings'
? referrers.by_earnings
: referrers.by_invited
).reduce((sum, r) => sum + r.earnings_today_kopeks, 0) / 100,
)} ${currencySymbol}`}
icon={<ClockIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('adminDashboard.period.week')}
value={`${formatAmount(
(referrersTab === 'earnings'
? referrers.by_earnings
: referrers.by_invited
).reduce((sum, r) => sum + r.earnings_week_kopeks, 0) / 100,
)} ${currencySymbol}`}
icon={<CalendarBlankIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('adminDashboard.period.month')}
value={`${formatAmount(
(referrersTab === 'earnings'
? referrers.by_earnings
: referrers.by_invited
).reduce((sum, r) => sum + r.earnings_month_kopeks, 0) / 100,
)} ${currencySymbol}`}
icon={<CalendarIcon className="h-5 w-5" />}
tone="neutral"
/>
</div>
</div>
)}

View File

@@ -4,7 +4,18 @@ import { useTranslation } from 'react-i18next';
import { partnerApi } from '../api/partners';
import { AdminBackButton } from '../components/admin';
import { useCurrency } from '../hooks/useCurrency';
import { XIcon } from '@/components/icons';
import { StatCard } from '@/components/stats';
import {
XIcon,
UsersIcon,
CheckCircleIcon,
UsersOnlineIcon,
PercentIcon,
BanknotesIcon,
CalendarIcon,
CalendarBlankIcon,
CalendarStarIcon,
} from '@/components/icons';
// Status badge config — keys must match backend PartnerStatus enum values
const statusConfig: Record<string, { labelKey: string; color: string; bgColor: string }> = {
@@ -112,30 +123,30 @@ export default function AdminPartnerDetail() {
<div className="space-y-6">
{/* Referral Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-2xl font-bold text-dark-100">{partner.total_referrals}</div>
<div className="text-xs text-dark-500">
{t('admin.partnerDetail.stats.totalReferrals')}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-2xl font-bold text-success-400">{partner.paid_referrals}</div>
<div className="text-xs text-dark-500">
{t('admin.partnerDetail.stats.paidReferrals')}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-2xl font-bold text-accent-400">{partner.active_referrals}</div>
<div className="text-xs text-dark-500">
{t('admin.partnerDetail.stats.activeReferrals')}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-2xl font-bold text-accent-400">{partner.conversion_to_paid}%</div>
<div className="text-xs text-dark-500">
{t('admin.partnerDetail.stats.conversionRate')}
</div>
</div>
<StatCard
label={t('admin.partnerDetail.stats.totalReferrals')}
value={partner.total_referrals}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.partnerDetail.stats.paidReferrals')}
value={partner.paid_referrals}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.partnerDetail.stats.activeReferrals')}
value={partner.active_referrals}
icon={<UsersOnlineIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.partnerDetail.stats.conversionRate')}
value={`${partner.conversion_to_paid}%`}
icon={<PercentIcon className="h-5 w-5" />}
tone="accent"
/>
</div>
{/* Earnings */}
@@ -144,38 +155,30 @@ export default function AdminPartnerDetail() {
{t('admin.partnerDetail.earnings.title')}
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-lg bg-dark-700/50 p-3">
<div className="mb-1 text-sm text-dark-400">
{t('admin.partnerDetail.earnings.allTime')}
</div>
<div className="text-lg font-medium text-success-400">
{formatWithCurrency(partner.earnings_all_time / 100)}
</div>
</div>
<div className="rounded-lg bg-dark-700/50 p-3">
<div className="mb-1 text-sm text-dark-400">
{t('admin.partnerDetail.earnings.today')}
</div>
<div className="text-lg font-medium text-dark-200">
{formatWithCurrency(partner.earnings_today / 100)}
</div>
</div>
<div className="rounded-lg bg-dark-700/50 p-3">
<div className="mb-1 text-sm text-dark-400">
{t('admin.partnerDetail.earnings.week')}
</div>
<div className="text-lg font-medium text-dark-200">
{formatWithCurrency(partner.earnings_week / 100)}
</div>
</div>
<div className="rounded-lg bg-dark-700/50 p-3">
<div className="mb-1 text-sm text-dark-400">
{t('admin.partnerDetail.earnings.month')}
</div>
<div className="text-lg font-medium text-dark-200">
{formatWithCurrency(partner.earnings_month / 100)}
</div>
</div>
<StatCard
label={t('admin.partnerDetail.earnings.allTime')}
value={formatWithCurrency(partner.earnings_all_time / 100)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.partnerDetail.earnings.today')}
value={formatWithCurrency(partner.earnings_today / 100)}
icon={<CalendarIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.partnerDetail.earnings.week')}
value={formatWithCurrency(partner.earnings_week / 100)}
icon={<CalendarBlankIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.partnerDetail.earnings.month')}
value={formatWithCurrency(partner.earnings_month / 100)}
icon={<CalendarStarIcon className="h-5 w-5" />}
tone="neutral"
/>
</div>
</div>

View File

@@ -9,7 +9,15 @@ import {
} from '../api/partners';
import { AdminBackButton } from '../components/admin';
import { useCurrency } from '../hooks/useCurrency';
import { ChevronRightIcon, SettingsIcon } from '@/components/icons';
import {
BanknotesIcon,
ChevronRightIcon,
InboxIcon,
PartnerIcon,
SettingsIcon,
UserPlusIcon,
} from '@/components/icons';
import { StatCard } from '@/components/stats';
export default function AdminPartners() {
const { t } = useTranslation();
@@ -58,24 +66,30 @@ export default function AdminPartners() {
{/* Stats Overview */}
{stats && (
<div className="mb-6 grid grid-cols-2 gap-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{stats.total_partners}</div>
<div className="text-sm text-dark-400">{t('admin.partners.totalPartners')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-accent-400">{stats.pending_applications}</div>
<div className="text-sm text-dark-400">{t('admin.partners.pendingApplications')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{stats.total_referrals}</div>
<div className="text-sm text-dark-400">{t('admin.partners.totalReferrals')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-success-400">
{formatWithCurrency(stats.total_earnings_kopeks / 100)}
</div>
<div className="text-sm text-dark-400">{t('admin.partners.totalEarnings')}</div>
</div>
<StatCard
label={t('admin.partners.totalPartners')}
value={stats.total_partners}
icon={<PartnerIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.partners.pendingApplications')}
value={stats.pending_applications}
icon={<InboxIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.partners.totalReferrals')}
value={stats.total_referrals}
icon={<UserPlusIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.partners.totalEarnings')}
value={formatWithCurrency(stats.total_earnings_kopeks / 100)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
/>
</div>
)}

View File

@@ -7,12 +7,16 @@ import { DateField } from '../components/DateField';
import { useCurrency } from '../hooks/useCurrency';
import type { PendingPayment, PaginatedResponse } from '../types';
import { usePlatform } from '../platform/hooks/usePlatform';
import { StatCard } from '@/components/stats';
import {
BackIcon,
SearchIcon,
CalendarIcon,
RefreshIcon,
CheckCircleIcon,
ChartBarIcon,
ClockIcon,
XCircleIcon,
} from '@/components/icons';
interface StatusBadgeProps {
@@ -38,38 +42,6 @@ function StatusBadge({ status }: StatusBadgeProps) {
);
}
interface StatCardProps {
label: string;
value: number;
color: 'blue' | 'amber' | 'green' | 'red';
isActive: boolean;
onClick: () => void;
}
function StatCard({ label, value, color, isActive, onClick }: StatCardProps) {
const colors: Record<string, string> = {
blue: 'border-accent-500/30 bg-accent-500/20 text-accent-400',
amber: 'border-warning-500/30 bg-warning-500/20 text-warning-400',
green: 'border-success-500/30 bg-success-500/20 text-success-400',
red: 'border-error-500/30 bg-error-500/20 text-error-400',
};
return (
<button
type="button"
onClick={onClick}
className={`rounded-xl border p-4 text-left transition-all ${
isActive
? colors[color]
: 'border-dark-700/50 bg-dark-800/50 text-dark-300 hover:border-dark-600'
}`}
>
<div className={`text-2xl font-bold ${isActive ? '' : 'text-dark-50'}`}>{value}</div>
<div className="text-sm opacity-80">{label}</div>
</button>
);
}
export default function AdminPayments() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -353,34 +325,62 @@ export default function AdminPayments() {
{/* Stats cards */}
{stats && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.payments.totalCount')}
value={stats.total}
color="blue"
isActive={statusFilter === 'all'}
<button
type="button"
onClick={() => handleStatusCardClick('all')}
/>
<StatCard
label={t('admin.payments.pendingCount')}
value={stats.pending}
color="amber"
isActive={statusFilter === 'pending'}
className={`rounded-xl text-left transition-all ${
statusFilter === 'all' ? 'ring-2 ring-accent-500' : ''
}`}
>
<StatCard
label={t('admin.payments.totalCount')}
value={stats.total}
icon={<ChartBarIcon className="h-5 w-5" />}
tone="accent"
/>
</button>
<button
type="button"
onClick={() => handleStatusCardClick('pending')}
/>
<StatCard
label={t('admin.payments.paidCount')}
value={stats.paid}
color="green"
isActive={statusFilter === 'paid'}
className={`rounded-xl text-left transition-all ${
statusFilter === 'pending' ? 'ring-2 ring-warning-500' : ''
}`}
>
<StatCard
label={t('admin.payments.pendingCount')}
value={stats.pending}
icon={<ClockIcon className="h-5 w-5" />}
tone="warning"
/>
</button>
<button
type="button"
onClick={() => handleStatusCardClick('paid')}
/>
<StatCard
label={t('admin.payments.cancelledCount')}
value={stats.cancelled}
color="red"
isActive={statusFilter === 'cancelled'}
className={`rounded-xl text-left transition-all ${
statusFilter === 'paid' ? 'ring-2 ring-success-500' : ''
}`}
>
<StatCard
label={t('admin.payments.paidCount')}
value={stats.paid}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="success"
/>
</button>
<button
type="button"
onClick={() => handleStatusCardClick('cancelled')}
/>
className={`rounded-xl text-left transition-all ${
statusFilter === 'cancelled' ? 'ring-2 ring-error-500' : ''
}`}
>
<StatCard
label={t('admin.payments.cancelledCount')}
value={stats.cancelled}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
</button>
</div>
)}

View File

@@ -6,16 +6,19 @@ import { rbacApi, AccessPolicy, AdminRole } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePlatform } from '@/platform/hooks/usePlatform';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { StatCard } from '@/components/stats';
import {
BackIcon,
BoltIcon,
CalendarIcon,
CheckCircleIcon,
ClockIcon,
EditIcon,
GlobeIcon,
PlusIcon,
ShieldIcon,
TrashIcon,
XCircleIcon,
} from '@/components/icons';
interface PolicyConditions {
@@ -243,28 +246,30 @@ export default function AdminPolicies() {
{/* Stats Overview */}
{sortedPolicies.length > 0 && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{sortedPolicies.length}</div>
<div className="text-xs text-dark-400">{t('admin.policies.stats.total')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-success-400">
{sortedPolicies.filter((p) => p.effect === 'allow').length}
</div>
<div className="text-xs text-dark-400">{t('admin.policies.stats.allow')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-error-400">
{sortedPolicies.filter((p) => p.effect === 'deny').length}
</div>
<div className="text-xs text-dark-400">{t('admin.policies.stats.deny')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-accent-400">
{sortedPolicies.filter((p) => p.is_active).length}
</div>
<div className="text-xs text-dark-400">{t('admin.policies.stats.active')}</div>
</div>
<StatCard
label={t('admin.policies.stats.total')}
value={sortedPolicies.length}
icon={<ShieldIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.policies.stats.allow')}
value={sortedPolicies.filter((p) => p.effect === 'allow').length}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.policies.stats.deny')}
value={sortedPolicies.filter((p) => p.effect === 'deny').length}
icon={<XCircleIcon className="h-5 w-5" />}
tone="error"
/>
<StatCard
label={t('admin.policies.stats.active')}
value={sortedPolicies.filter((p) => p.is_active).length}
icon={<BoltIcon className="h-5 w-5" />}
tone="accent"
/>
</div>
)}

View File

@@ -5,7 +5,16 @@ import { useTranslation } from 'react-i18next';
import { promocodesApi, PromoGroup } from '../api/promocodes';
import { usePlatform } from '../platform/hooks/usePlatform';
import { useFocusTrap } from '../hooks/useFocusTrap';
import { BackIcon, PlusIcon, EditIcon, TrashIcon, UsersIcon } from '@/components/icons';
import {
BackIcon,
PlusIcon,
EditIcon,
TrashIcon,
UsersIcon,
TagIcon,
BoltIcon,
} from '@/components/icons';
import { StatCard } from '@/components/stats';
export default function AdminPromoGroups() {
const { t } = useTranslation();
@@ -66,22 +75,24 @@ export default function AdminPromoGroups() {
{/* Stats */}
{groups.length > 0 && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{groups.length}</div>
<div className="text-xs text-dark-400">{t('admin.promoGroups.stats.total')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-accent-400">
{groups.reduce((sum, g) => sum + g.members_count, 0)}
</div>
<div className="text-xs text-dark-400">{t('admin.promoGroups.stats.members')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-warning-400">
{groups.filter((g) => g.auto_assign_total_spent_kopeks).length}
</div>
<div className="text-xs text-dark-400">{t('admin.promoGroups.stats.autoAssign')}</div>
</div>
<StatCard
label={t('admin.promoGroups.stats.total')}
value={groups.length}
icon={<TagIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.promoGroups.stats.members')}
value={groups.reduce((sum, g) => sum + g.members_count, 0)}
icon={<UsersIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.promoGroups.stats.autoAssign')}
value={groups.filter((g) => g.auto_assign_total_spent_kopeks).length}
icon={<BoltIcon className="h-5 w-5" />}
tone="warning"
/>
</div>
)}

View File

@@ -4,7 +4,15 @@ import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { promocodesApi, PromoCodeType } from '../api/promocodes';
import { AdminBackButton } from '../components/admin';
import { EditIcon, ClockIcon, UserIcon } from '@/components/icons';
import { StatCard } from '../components/stats';
import {
EditIcon,
ClockIcon,
UserIcon,
ChartBarIcon,
SparklesIcon,
TicketIcon,
} from '@/components/icons';
// Helper functions
const getTypeLabel = (type: PromoCodeType): string => {
@@ -125,25 +133,29 @@ export default function AdminPromocodeStats() {
<div className="space-y-6">
{/* Stats Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="mb-1 text-3xl font-bold text-dark-100">{promocode.total_uses}</div>
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.totalUses')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="mb-1 text-3xl font-bold text-success-400">{promocode.today_uses}</div>
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.today')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="mb-1 text-3xl font-bold text-accent-400">
{promocode.max_uses === 0 ? '∞' : promocode.uses_left}
</div>
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.remaining')}</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label={t('admin.promocodes.stats.totalUses')}
value={promocode.total_uses}
icon={<ChartBarIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.promocodes.stats.today')}
value={promocode.today_uses}
icon={<SparklesIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.promocodes.stats.remaining')}
value={promocode.max_uses === 0 ? '∞' : promocode.uses_left}
icon={<TicketIcon className="h-5 w-5" />}
tone="accent"
/>
</div>
{/* Details */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<h4 className="mb-4 font-medium text-dark-200">{t('admin.promocodes.stats.details')}</h4>
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div className="flex justify-between rounded-lg bg-dark-700/50 p-3">
@@ -221,7 +233,7 @@ export default function AdminPromocodeStats() {
</div>
{/* Usage History */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<h4 className="mb-4 flex items-center gap-2 font-medium text-dark-200">
<ClockIcon className="h-4 w-4" />
{t('admin.promocodes.stats.usageHistory')}

View File

@@ -14,7 +14,12 @@ import {
CheckIcon,
CopyIcon,
ChartIcon,
ChartBarIcon,
CheckCircleIcon,
TagIcon,
TicketIcon,
} from '@/components/icons';
import { StatCard } from '../components/stats';
// Helper functions
const getTypeLabel = (type: PromoCodeType): string => {
@@ -113,30 +118,30 @@ export default function AdminPromocodes() {
{/* Stats Overview */}
{promocodes.length > 0 && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{promocodes.length}</div>
<div className="text-xs text-dark-400">
{t('admin.promocodes.stats.totalPromocodes')}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-success-400">
{promocodes.filter((p) => p.is_active && p.is_valid).length}
</div>
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.activeCount')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-accent-400">
{promocodes.reduce((sum, p) => sum + p.current_uses, 0)}
</div>
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.usagesCount')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-warning-400">
{promocodes.filter((p) => p.uses_left === 0 && p.max_uses > 0).length}
</div>
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.exhausted')}</div>
</div>
<StatCard
label={t('admin.promocodes.stats.totalPromocodes')}
value={promocodes.length}
icon={<TagIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.promocodes.stats.activeCount')}
value={promocodes.filter((p) => p.is_active && p.is_valid).length}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('admin.promocodes.stats.usagesCount')}
value={promocodes.reduce((sum, p) => sum + p.current_uses, 0)}
icon={<ChartBarIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.promocodes.stats.exhausted')}
value={promocodes.filter((p) => p.uses_left === 0 && p.max_uses > 0).length}
icon={<TicketIcon className="h-5 w-5" />}
tone="warning"
/>
</div>
)}

View File

@@ -19,6 +19,7 @@ import { usePlatform } from '../platform/hooks/usePlatform';
import { formatUptime } from '../utils/format';
import { getFlagEmoji } from '../utils/subscriptionHelpers';
import Twemoji from 'react-twemoji';
import { StatCard } from '../components/stats';
import {
ServerIcon,
ChartIcon,
@@ -108,47 +109,6 @@ const formatSpeed = (bytesPerSec: number): string => {
return `${(bits / 1e9).toFixed(2)} Gb/s`;
};
interface StatCardProps {
label: string;
value: string | number;
icon: React.ReactNode;
color?: string;
subValue?: string;
}
// 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">
<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">
<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>
);
}
function NodeTrafficBreakdown({
title,
items,
@@ -684,25 +644,25 @@ function OverviewTab({
label={t('admin.remnawave.overview.usersOnline', 'Users Online')}
value={stats.system.users_online}
icon={<UsersIcon />}
color="green"
tone="success"
/>
<StatCard
label={t('admin.remnawave.overview.totalUsers', 'Total Users')}
value={stats.system.total_users}
icon={<UsersIcon />}
color="blue"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.nodesOnline', 'Nodes Online')}
value={`${stats.system.nodes_online} / ${stats.system.total_nodes}`}
icon={<GlobeIcon />}
color={stats.system.nodes_online < stats.system.total_nodes ? 'orange' : 'purple'}
tone={stats.system.nodes_online < stats.system.total_nodes ? 'warning' : 'accent'}
/>
<StatCard
label={t('admin.remnawave.overview.active24h', 'Активны за 24ч')}
value={stats.system.users_last_day}
icon={<ServerIcon className="h-5 w-5" />}
color="orange"
tone="warning"
/>
</div>
</div>
@@ -718,19 +678,19 @@ function OverviewTab({
label={t('admin.remnawave.overview.download', 'Download')}
value={formatBytes(stats.bandwidth.realtime_download)}
icon={<DownloadIcon className="h-5 w-5" />}
color="green"
tone="success"
/>
<StatCard
label={t('admin.remnawave.overview.upload', 'Upload')}
value={formatBytes(stats.bandwidth.realtime_upload)}
icon={<UploadIcon className="h-5 w-5" />}
color="blue"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.total', 'Total')}
value={formatBytes(stats.bandwidth.realtime_total)}
icon={<ChartIcon className="h-5 w-5" />}
color="purple"
tone="accent"
/>
</div>
</div>
@@ -746,20 +706,20 @@ function OverviewTab({
label={t('admin.remnawave.overview.cpu', 'CPU Cores')}
value={stats.server_info.cpu_cores}
icon={<CpuIcon className="h-5 w-5" />}
color="accent"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.memory', 'Memory')}
value={`${memoryUsedPercent}%`}
subValue={`${formatBytes(stats.server_info.memory_used)} / ${formatBytes(stats.server_info.memory_total)}`}
icon={<MemoryIcon className="h-5 w-5" />}
color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'}
tone={memoryUsedPercent > 80 ? 'error' : memoryUsedPercent > 60 ? 'warning' : 'success'}
/>
<StatCard
label={t('admin.remnawave.overview.uptime', 'Uptime')}
value={formatUptime(stats.server_info.uptime_seconds)}
icon={<StatUptimeIcon className="h-5 w-5" />}
color="blue"
tone="accent"
/>
</div>
</div>
@@ -775,31 +735,31 @@ function OverviewTab({
label={t('admin.remnawave.overview.traffic2days', '2 days')}
value={formatBytes(stats.traffic_periods.last_2_days.current)}
icon={<CalendarIcon className="h-5 w-5" />}
color="accent"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.traffic7days', '7 days')}
value={formatBytes(stats.traffic_periods.last_7_days.current)}
icon={<ChartDonutIcon className="h-5 w-5" />}
color="blue"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.traffic30days', '30 days')}
value={formatBytes(stats.traffic_periods.last_30_days.current)}
icon={<ChartPieIcon className="h-5 w-5" />}
color="green"
tone="success"
/>
<StatCard
label={t('admin.remnawave.overview.trafficMonth', 'Month')}
value={formatBytes(stats.traffic_periods.current_month.current)}
icon={<CalendarBlankIcon className="h-5 w-5" />}
color="purple"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.trafficYear', 'Year')}
value={formatBytes(stats.traffic_periods.current_year.current)}
icon={<CalendarStarIcon className="h-5 w-5" />}
color="orange"
tone="warning"
/>
</div>
</div>
@@ -817,7 +777,7 @@ function OverviewTab({
label={status}
value={count}
icon={userStatusIcon(status)}
color={status === 'ACTIVE' ? 'green' : status === 'DISABLED' ? 'red' : 'accent'}
tone={status === 'ACTIVE' ? 'success' : status === 'DISABLED' ? 'error' : 'accent'}
/>
))}
</div>
@@ -835,19 +795,19 @@ function OverviewTab({
label={t('admin.remnawave.overview.lifetimeTraffic', 'Трафик за всё время')}
value={formatBytes(recap.total.traffic_bytes)}
icon={<ChartIcon className="h-5 w-5" />}
color="purple"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.thisMonthTraffic', 'Трафик за месяц')}
value={formatBytes(recap.this_month.traffic_bytes)}
icon={<ChartIcon className="h-5 w-5" />}
color="blue"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.countries', 'Стран')}
value={recap.total.distinct_countries}
icon={<GlobeIcon className="h-5 w-5" />}
color="green"
tone="success"
/>
<StatCard
label={t('admin.remnawave.overview.panelVersion', 'Версия панели')}
@@ -858,7 +818,7 @@ function OverviewTab({
: undefined
}
icon={<ServerIcon className="h-5 w-5" />}
color="accent"
tone="accent"
/>
</div>
</div>
@@ -936,26 +896,26 @@ function OverviewTab({
label={t('admin.remnawave.overview.panelRam', 'RAM процесса')}
value={formatBytes(health.rss_bytes)}
icon={<MemoryIcon className="h-5 w-5" />}
color="blue"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.heap', 'Heap')}
value={formatBytes(health.heap_used_bytes)}
subValue={`/ ${formatBytes(health.heap_total_bytes)}`}
icon={<ChartIcon className="h-5 w-5" />}
color="purple"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.overview.eventLoopP99', 'Event-loop p99')}
value={`${health.event_loop_p99_ms.toFixed(1)} ms`}
icon={<PulseIcon className="h-5 w-5" />}
color={health.event_loop_p99_ms > 50 ? 'red' : 'green'}
tone={health.event_loop_p99_ms > 50 ? 'error' : 'success'}
/>
<StatCard
label={t('admin.remnawave.overview.panelUptime', 'Аптайм панели')}
value={formatUptime(health.uptime_seconds)}
icon={<StatUptimeIcon className="h-5 w-5" />}
color="accent"
tone="accent"
/>
</div>
</div>
@@ -1037,31 +997,31 @@ function NodesTab({
label={t('admin.remnawave.nodes.stats.total', 'Total')}
value={stats.total}
icon={<ServerIcon />}
color="accent"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.nodes.stats.online', 'Online')}
value={stats.online}
icon={<HeartbeatIcon />}
color="green"
tone="success"
/>
<StatCard
label={t('admin.remnawave.nodes.stats.offline', 'Offline')}
value={stats.offline}
icon={<WarningCircleIcon />}
color="red"
tone="error"
/>
<StatCard
label={t('admin.remnawave.nodes.stats.disabled', 'Disabled')}
value={stats.disabled}
icon={<PowerIcon />}
color="accent"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.nodes.stats.users', 'Users')}
value={stats.totalUsers}
icon={<UsersIcon />}
color="blue"
tone="accent"
/>
</div>
@@ -1170,25 +1130,25 @@ function SquadsTab({
label={t('admin.remnawave.squads.stats.total', 'Total')}
value={stats.total}
icon={<ServerIcon className="h-5 w-5" />}
color="accent"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.squads.stats.synced', 'Synced')}
value={stats.synced}
icon={<SyncIcon />}
color="green"
tone="success"
/>
<StatCard
label={t('admin.remnawave.squads.stats.available', 'Available')}
value={stats.available}
icon={<ServerIcon className="h-5 w-5" />}
color="blue"
tone="accent"
/>
<StatCard
label={t('admin.remnawave.squads.stats.members', 'Members')}
value={stats.totalMembers}
icon={<UsersIcon />}
color="purple"
tone="accent"
/>
</div>

View File

@@ -3,7 +3,8 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminRemnawaveApi, SquadWithLocalInfo } from '../api/adminRemnawave';
import { AdminBackButton } from '../components/admin';
import { ServerIcon, UsersIcon, CheckIcon, XIcon } from '../components/icons';
import { ServerIcon, UsersIcon, CheckIcon, XIcon, BanknotesIcon } from '../components/icons';
import { StatCard } from '@/components/stats';
import Twemoji from 'react-twemoji';
import { getFlagEmoji } from '../utils/subscriptionHelpers';
@@ -126,43 +127,33 @@ export default function AdminRemnawaveSquadDetail() {
{t('admin.remnawave.squads.statsTitle', 'Statistics')}
</h3>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-lg bg-dark-700/50 p-4">
<div className="flex items-center gap-2 text-dark-400">
<UsersIcon className="h-4 w-4" />
<span className="text-sm">{t('admin.remnawave.squads.members', 'Members')}</span>
</div>
<p className="mt-1 text-2xl font-bold text-dark-100">{squad.members_count}</p>
</div>
<div className="rounded-lg bg-dark-700/50 p-4">
<div className="flex items-center gap-2 text-dark-400">
<ServerIcon className="h-4 w-4" />
<span className="text-sm">{t('admin.remnawave.squads.inbounds', 'Inbounds')}</span>
</div>
<p className="mt-1 text-2xl font-bold text-dark-100">{squad.inbounds_count}</p>
</div>
<StatCard
label={t('admin.remnawave.squads.members', 'Members')}
value={squad.members_count}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.remnawave.squads.inbounds', 'Inbounds')}
value={squad.inbounds_count}
icon={<ServerIcon className="h-5 w-5" />}
tone="neutral"
/>
{squad.is_synced && (
<>
<div className="rounded-lg bg-dark-700/50 p-4">
<div className="flex items-center gap-2 text-dark-400">
<UsersIcon className="h-4 w-4" />
<span className="text-sm">{t('admin.remnawave.squads.users', 'Users')}</span>
</div>
<p className="mt-1 text-2xl font-bold text-dark-100">
{squad.current_users ?? 0}
<span className="text-sm font-normal text-dark-400">
{' '}
/ {squad.max_users ?? '∞'}
</span>
</p>
</div>
<div className="rounded-lg bg-dark-700/50 p-4">
<div className="flex items-center gap-2 text-dark-400">
<span className="text-sm">{t('admin.remnawave.squads.price', 'Price')}</span>
</div>
<p className="mt-1 text-2xl font-bold text-dark-100">
{((squad.price_kopeks ?? 0) / 100).toFixed(0)}
</p>
</div>
<StatCard
label={t('admin.remnawave.squads.users', 'Users')}
value={squad.current_users ?? 0}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
subValue={`/ ${squad.max_users ?? '∞'}`}
/>
<StatCard
label={t('admin.remnawave.squads.price', 'Price')}
value={`${((squad.price_kopeks ?? 0) / 100).toFixed(0)}`}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="neutral"
/>
</>
)}
</div>

View File

@@ -7,7 +7,16 @@ import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePermissionStore } from '@/store/permissions';
import { usePlatform } from '@/platform/hooks/usePlatform';
import { useFocusTrap } from '@/hooks/useFocusTrap';
import { BackIcon, PlusIcon, EditIcon, TrashIcon, ShieldIcon } from '@/components/icons';
import {
BackIcon,
PlusIcon,
EditIcon,
TrashIcon,
ShieldIcon,
CheckCircleIcon,
CogIcon,
} from '@/components/icons';
import { StatCard } from '@/components/stats';
export default function AdminRoles() {
const { t } = useTranslation();
@@ -90,22 +99,24 @@ export default function AdminRoles() {
{/* Stats Overview */}
{sortedRoles.length > 0 && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-dark-100">{sortedRoles.length}</div>
<div className="text-xs text-dark-400">{t('admin.roles.stats.totalRoles')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-accent-400">
{sortedRoles.filter((r) => r.is_active).length}
</div>
<div className="text-xs text-dark-400">{t('admin.roles.stats.active')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-warning-400">
{sortedRoles.filter((r) => r.is_system).length}
</div>
<div className="text-xs text-dark-400">{t('admin.roles.stats.system')}</div>
</div>
<StatCard
label={t('admin.roles.stats.totalRoles')}
value={sortedRoles.length}
icon={<ShieldIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.roles.stats.active')}
value={sortedRoles.filter((r) => r.is_active).length}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.roles.stats.system')}
value={sortedRoles.filter((r) => r.is_system).length}
icon={<CogIcon className="h-5 w-5" />}
tone="warning"
/>
</div>
)}

View File

@@ -9,7 +9,17 @@ import { adminApi, AdminTicket, AdminTicketDetail } from '../api/admin';
import { ticketsApi } from '../api/tickets';
import { copyToClipboard as copyText } from '../utils/clipboard';
import { usePlatform } from '../platform/hooks/usePlatform';
import { BackIcon, SettingsIcon, TicketIcon, XIcon } from '@/components/icons';
import {
BackIcon,
CheckCircleIcon,
ClockIcon,
InboxIcon,
SettingsIcon,
TicketIcon,
XCircleIcon,
XIcon,
} from '@/components/icons';
import { StatCard } from '@/components/stats';
interface MediaAttachment {
id: string;
@@ -278,25 +288,37 @@ export default function AdminTickets() {
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<div className="card text-center">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">{t('admin.tickets.total')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-accent-400">{stats.open}</div>
<div className="stat-label">{t('admin.tickets.statusOpen')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-warning-400">{stats.pending}</div>
<div className="stat-label">{t('admin.tickets.statusPending')}</div>
</div>
<div className="card text-center">
<div className="stat-value text-success-400">{stats.answered}</div>
<div className="stat-label">{t('admin.tickets.statusAnswered')}</div>
</div>
<div className="card col-span-2 text-center sm:col-span-1">
<div className="stat-value text-dark-400">{stats.closed}</div>
<div className="stat-label">{t('admin.tickets.statusClosed')}</div>
<StatCard
label={t('admin.tickets.total')}
value={stats.total}
icon={<TicketIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.tickets.statusOpen')}
value={stats.open}
icon={<InboxIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.tickets.statusPending')}
value={stats.pending}
icon={<ClockIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.tickets.statusAnswered')}
value={stats.answered}
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="success"
/>
<div className="col-span-2 sm:col-span-1">
<StatCard
label={t('admin.tickets.statusClosed')}
value={stats.closed}
icon={<XCircleIcon className="h-5 w-5" />}
tone="neutral"
/>
</div>
</div>
)}

View File

@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { useCurrency } from '../hooks/useCurrency';
import { adminUsersApi, type UserListItem } from '../api/adminUsers';
import { usePlatform } from '../platform/hooks/usePlatform';
import { StatCard } from '@/components/stats';
import {
BackIcon,
SearchIcon,
@@ -12,33 +13,13 @@ import {
ChevronRightIcon,
RefreshIcon,
TelegramSmallIcon as TelegramIcon,
UsersIcon,
CheckCircleIcon,
SubscriptionIcon,
UserPlusIcon,
BanIcon,
} from '@/components/icons';
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
color: 'blue' | 'green' | 'yellow' | 'red' | 'purple';
}
function StatCard({ title, value, subtitle, color }: StatCardProps) {
const colors = {
blue: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
green: 'bg-success-500/20 text-success-400 border-success-500/30',
yellow: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
red: 'bg-error-500/20 text-error-400 border-error-500/30',
purple: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
};
return (
<div className={`rounded-xl border p-4 ${colors[color]}`}>
<div className="mb-1 text-2xl font-bold">{value}</div>
<div className="text-sm opacity-80">{title}</div>
{subtitle && <div className="mt-1 text-xs opacity-60">{subtitle}</div>}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
active: 'bg-success-500/20 text-success-400 border-success-500/30',
@@ -208,26 +189,35 @@ export default function AdminUsers() {
{/* Stats */}
{stats && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<StatCard title={t('admin.users.stats.total')} value={stats.total_users} color="blue" />
<StatCard
title={t('admin.users.stats.active')}
label={t('admin.users.stats.total')}
value={stats.total_users}
icon={<UsersIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
label={t('admin.users.stats.active')}
value={stats.active_users}
color="green"
icon={<CheckCircleIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
title={t('admin.users.stats.withSubscription')}
label={t('admin.users.stats.withSubscription')}
value={stats.users_with_active_subscription}
color="purple"
icon={<SubscriptionIcon className="h-5 w-5" />}
tone="accent"
/>
<StatCard
title={t('admin.users.stats.newToday')}
label={t('admin.users.stats.newToday')}
value={stats.new_today}
color="yellow"
icon={<UserPlusIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
title={t('admin.users.stats.blocked')}
label={t('admin.users.stats.blocked')}
value={stats.blocked_users}
color="red"
icon={<BanIcon className="h-5 w-5" />}
tone="error"
/>
</div>
)}

View File

@@ -4,7 +4,8 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { withdrawalApi, AdminWithdrawalItem } from '../api/withdrawals';
import { AdminBackButton } from '../components/admin';
import { ChevronRightIcon } from '@/components/icons';
import { ChevronRightIcon, ClockIcon, WalletIcon } from '@/components/icons';
import { StatCard } from '@/components/stats';
import { useCurrency } from '../hooks/useCurrency';
import { formatDate, getWithdrawalStatusBadge, getRiskColor } from '../utils/withdrawalUtils';
@@ -57,20 +58,18 @@ export default function AdminWithdrawals() {
{/* Overview Stats */}
{data && (
<div className="mb-6 grid grid-cols-2 gap-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-warning-400">{pendingCount}</div>
<div className="text-sm text-dark-400">
{t('admin.withdrawals.overview.pendingCount')}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="text-2xl font-bold text-warning-400">
{formatWithCurrency(pendingTotal / 100, 0)}
</div>
<div className="text-sm text-dark-400">
{t('admin.withdrawals.overview.pendingAmount')}
</div>
</div>
<StatCard
label={t('admin.withdrawals.overview.pendingCount')}
value={pendingCount}
icon={<ClockIcon className="h-5 w-5" />}
tone="warning"
/>
<StatCard
label={t('admin.withdrawals.overview.pendingAmount')}
value={formatWithCurrency(pendingTotal / 100, 0)}
icon={<WalletIcon className="h-5 w-5" />}
tone="warning"
/>
</div>
)}

View File

@@ -10,15 +10,23 @@ import { partnerApi } from '../api/partners';
import { withdrawalApi } from '../api/withdrawals';
import { CampaignCard } from '../components/partner/CampaignCard';
import { useCurrency } from '../hooks/useCurrency';
import { StatCard } from '@/components/stats';
import {
ArrowDownIcon,
ArrowUpIcon,
BanknotesIcon,
CardIcon,
CheckIcon,
ClockIcon,
CopyIcon,
ExclamationIcon,
GiftIcon,
LinkIcon,
PartnerIcon,
PercentIcon,
ShareIcon,
TelegramIcon,
UserPlusIcon,
UsersIcon,
WalletIcon,
} from '@/components/icons';
@@ -131,33 +139,33 @@ export default function Referral() {
<div className="bento-card">
<h2 className="mb-4 text-lg font-semibold text-dark-100">{t('referral.terms.title')}</h2>
<div className={`grid grid-cols-2 gap-4 ${gridCols}`}>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{terms.commission_percent}%
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}
</div>
</div>
<StatCard
label={t('referral.terms.commission')}
value={`${terms.commission_percent}%`}
icon={<PercentIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('referral.terms.minTopup')}
value={`${formatAmount(terms.minimum_topup_rubles)} ${currencySymbol}`}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="neutral"
/>
{showNewUserBonus && (
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div>
<div className="mt-1 text-lg font-semibold text-success-400">
{formatPositive(terms.first_topup_bonus_rubles)}
</div>
</div>
<StatCard
label={t('referral.terms.newUserBonus')}
value={formatPositive(terms.first_topup_bonus_rubles)}
icon={<GiftIcon className="h-5 w-5" />}
tone="success"
/>
)}
{showInviterBonus && (
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div>
<div className="mt-1 text-lg font-semibold text-success-400">
{formatPositive(terms.inviter_bonus_rubles)}
</div>
</div>
<StatCard
label={t('referral.terms.inviterBonus')}
value={formatPositive(terms.inviter_bonus_rubles)}
icon={<UserPlusIcon className="h-5 w-5" />}
tone="success"
/>
)}
</div>
</div>
@@ -237,23 +245,27 @@ export default function Referral() {
{/* Stats Cards */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 md:gap-4">
<div className="bento-card-hover col-span-2 md:col-span-1">
<div className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</div>
<div className="stat-value mt-1">{info?.total_referrals || 0}</div>
<div className="mt-1 text-sm text-dark-500">
{info?.active_referrals || 0} {t('referral.stats.activeReferrals').toLowerCase()}
</div>
</div>
<div className="bento-card-hover">
<div className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</div>
<div className="stat-value mt-1 text-success-400">
{formatPositive(info?.total_earnings_rubles || 0)}
</div>
</div>
<div className="bento-card-hover">
<div className="text-sm text-dark-400">{t('referral.stats.commissionRate')}</div>
<div className="stat-value mt-1 text-accent-400">{info?.commission_percent || 0}%</div>
<div className="col-span-2 md:col-span-1">
<StatCard
label={t('referral.stats.totalReferrals')}
value={info?.total_referrals || 0}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
subValue={`${info?.active_referrals || 0} ${t('referral.stats.activeReferrals').toLowerCase()}`}
/>
</div>
<StatCard
label={t('referral.stats.totalEarnings')}
value={formatPositive(info?.total_earnings_rubles || 0)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="success"
/>
<StatCard
label={t('referral.stats.commissionRate')}
value={`${info?.commission_percent || 0}%`}
icon={<PercentIcon className="h-5 w-5" />}
tone="accent"
/>
</div>
{/* Referral Links */}
@@ -545,38 +557,38 @@ export default function Referral() {
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<div className="col-span-2 rounded-xl bg-dark-800/30 p-4 md:col-span-1">
<div className="text-sm text-dark-500">{t('referral.withdrawal.available')}</div>
<div className="mt-1 text-2xl font-bold text-success-400">
{formatWithCurrency(withdrawalBalance.available_total / 100)}
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">
{t('referral.withdrawal.totalEarned')}
</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{formatWithCurrency(withdrawalBalance.total_earned / 100)}
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.withdrawal.withdrawn')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{formatWithCurrency(withdrawalBalance.withdrawn / 100)}
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.withdrawal.spent')}</div>
<div className="mt-1 text-lg font-semibold text-dark-100">
{formatWithCurrency(withdrawalBalance.referral_spent / 100)}
</div>
</div>
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="text-sm text-dark-500">{t('referral.withdrawal.pending')}</div>
<div className="mt-1 text-lg font-semibold text-warning-400">
{formatWithCurrency(withdrawalBalance.pending / 100)}
</div>
<div className="col-span-2 md:col-span-1">
<StatCard
label={t('referral.withdrawal.available')}
value={formatWithCurrency(withdrawalBalance.available_total / 100)}
icon={<WalletIcon className="h-5 w-5" />}
tone="success"
/>
</div>
<StatCard
label={t('referral.withdrawal.totalEarned')}
value={formatWithCurrency(withdrawalBalance.total_earned / 100)}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('referral.withdrawal.withdrawn')}
value={formatWithCurrency(withdrawalBalance.withdrawn / 100)}
icon={<ArrowUpIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('referral.withdrawal.spent')}
value={formatWithCurrency(withdrawalBalance.referral_spent / 100)}
icon={<CardIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('referral.withdrawal.pending')}
value={formatWithCurrency(withdrawalBalance.pending / 100)}
icon={<ArrowDownIcon className="h-5 w-5" />}
tone="warning"
/>
</div>
<div className="mt-4">

View File

@@ -1,4 +1,12 @@
import { useTranslation } from 'react-i18next';
import { StatCard } from '@/components/stats';
import {
BanknotesIcon,
CampaignIcon,
PartnerIcon,
UsersIcon,
WalletIcon,
} from '@/components/icons';
import type { NetworkGraphData } from '@/types/referralNetwork';
import { formatKopeksToRubles } from '../utils';
@@ -15,45 +23,37 @@ export function NetworkStats({ data, className }: NetworkStatsProps) {
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-2 backdrop-blur-md sm:p-3 ${className ?? ''}`}
>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:gap-x-6 sm:gap-y-2">
<div>
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.stats.totalUsers')}
</p>
<p className="font-mono text-sm font-semibold text-dark-100">
{data.total_users.toLocaleString()}
</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.stats.totalReferrers')}
</p>
<p className="font-mono text-sm font-semibold text-dark-100">
{data.total_referrers.toLocaleString()}
</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.stats.totalCampaigns')}
</p>
<p className="font-mono text-sm font-semibold text-dark-100">
{data.total_campaigns.toLocaleString()}
</p>
</div>
<div>
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.stats.subscriptionRevenue')}
</p>
<p className="font-mono text-sm font-semibold text-accent-400">
{formatKopeksToRubles(data.total_subscription_revenue_kopeks)}
</p>
</div>
<div className="col-span-2 border-t border-dark-700/30 pt-1.5">
<p className="text-[10px] font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.stats.totalEarnings')}
</p>
<p className="font-mono text-sm font-semibold text-dark-100">
{formatKopeksToRubles(data.total_earnings_kopeks)}
</p>
<StatCard
label={t('admin.referralNetwork.stats.totalUsers')}
value={data.total_users.toLocaleString()}
icon={<UsersIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.referralNetwork.stats.totalReferrers')}
value={data.total_referrers.toLocaleString()}
icon={<PartnerIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.referralNetwork.stats.totalCampaigns')}
value={data.total_campaigns.toLocaleString()}
icon={<CampaignIcon className="h-5 w-5" />}
tone="neutral"
/>
<StatCard
label={t('admin.referralNetwork.stats.subscriptionRevenue')}
value={`${formatKopeksToRubles(data.total_subscription_revenue_kopeks)}`}
icon={<BanknotesIcon className="h-5 w-5" />}
tone="accent"
/>
<div className="col-span-2">
<StatCard
label={t('admin.referralNetwork.stats.totalEarnings')}
value={`${formatKopeksToRubles(data.total_earnings_kopeks)}`}
icon={<WalletIcon className="h-5 w-5" />}
tone="neutral"
/>
</div>
</div>
</div>