feat: admin dashboard UI — DashboardSection as default panel

AdminPanel: Сводка раздел первый (initialSection='dashboard')
DashboardSection: users stats, channels by platform, posts stats,
  revenue vs AI costs cards, drafts pending alert, registrations bar chart 14d
SECTIONS: +Dashboard, +Engine (Движок)
API route: /api/admin/dashboard proxy
This commit is contained in:
Ник (Claude)
2026-06-13 00:10:40 +03:00
parent a5f6c080bd
commit 92b743512c
3 changed files with 165 additions and 7 deletions
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
export async function GET() {
const user = await requireUser();
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
const res = await fetch(`${ENGINE_URL}/api/admin/dashboard`, {
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
cache: 'no-store',
});
return NextResponse.json(await res.json());
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default async function SystemPage({ searchParams }) {
return ( return (
<> <>
<Header user={user} /> <Header user={user} />
<AdminPanel initialSection={searchParams?.section || 'settings'} /> <AdminPanel initialSection={searchParams?.section || 'dashboard'} />
</> </>
); );
} }
+149 -6
View File
@@ -8,12 +8,13 @@ import AdminBilling from './admin/AdminBilling';
// Sidebar navigation // Sidebar navigation
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
const SECTIONS = [ const SECTIONS = [
{ id: 'settings', label: 'AI-провайдеры', icon: Settings2, desc: 'Ключи aiprimetech и routerai' }, { id: 'dashboard', label: 'Сводка', icon: BarChart3, desc: 'Пользователи, посты, финансы' },
{ id: 'engine', label: 'Движок', icon: Zap, desc: 'URL, Telegram, авто-черновики' }, { id: 'settings', label: 'AI-провайдеры', icon: Settings2, desc: 'Ключи aiprimetech и routerai' },
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' }, { id: 'engine', label: 'Движок', icon: Zap, desc: 'URL, Telegram, авто-черновики' },
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' }, { id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' }, { id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' }, { id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
]; ];
export default function AdminPanel({ initialSection = 'settings' }) { export default function AdminPanel({ initialSection = 'settings' }) {
@@ -56,6 +57,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{section === 'dashboard' && <DashboardSection />}
{section === 'settings' && <SettingsSection categories={['ai_providers', 'photo_search']} />} {section === 'settings' && <SettingsSection categories={['ai_providers', 'photo_search']} />}
{section === 'engine' && <SettingsSection categories={['engine']} />} {section === 'engine' && <SettingsSection categories={['engine']} />}
{section === 'payments' && <SettingsSection categories={['payments']} />} {section === 'payments' && <SettingsSection categories={['payments']} />}
@@ -467,3 +469,144 @@ function PlansSection() {
</div> </div>
); );
} }
// ──────────────────────────────────────────────────────────────
// Dashboard section
// ──────────────────────────────────────────────────────────────
function DashboardSection() {
const [data, setData] = useState(null);
const [loading, setLoading]= useState(true);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/dashboard').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
if (!data && !loading) load();
if (!data && loading) { load(); }
const PLATFORM_ICONS = { telegram: '✈️', vk: '🔵', max: '🟣' };
function Stat({ label, value, sub, accent }) {
return (
<div className={`card p-4 ${accent ? 'border-accent/40 bg-accent/5' : ''}`}>
<div className={`text-2xl font-bold ${accent ? 'text-accent' : ''}`}>{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
{sub && <div className="text-xs text-gray-500 mt-1">{sub}</div>}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-lg">Сводка</h2>
<button onClick={load} className="btn-ghost p-2"><RefreshCw className="w-4 h-4" /></button>
</div>
{loading && <div className="py-12 text-center"><Loader2 className="w-6 h-6 animate-spin mx-auto text-accent" /></div>}
{data && (<>
{/* Пользователи */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Пользователи</p>
<div className="grid grid-cols-3 gap-3">
<Stat label="Всего пользователей" value={data.users.total} accent />
<Stat label="Новых за 7 дней" value={data.users.new_7d} />
<Stat label="Новых за 30 дней" value={data.users.new_30d} />
</div>
</div>
{/* Каналы */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Каналы</p>
<div className="grid grid-cols-4 gap-3">
<Stat label="Всего каналов"
value={data.channels.reduce((s, c) => s + c.cnt, 0)} />
{data.channels.map(c => (
<Stat key={c.platform}
label={`${PLATFORM_ICONS[c.platform] || '📢'} ${c.platform}`}
value={c.cnt} />
))}
</div>
</div>
{/* Посты */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Публикации</p>
<div className="grid grid-cols-3 gap-3">
<Stat label="Всего опубликовано" value={data.posts.total} />
<Stat label="За последние 24 часа" value={data.posts.today} />
<Stat label="За 7 дней" value={data.posts.week} />
</div>
</div>
{/* Финансы */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Финансы</p>
<div className="grid grid-cols-2 gap-3">
<div className="card p-4 border-green-500/30 bg-green-500/5">
<div className="text-2xl font-bold text-green-400">{data.revenue.month_rub.toLocaleString('ru-RU')}</div>
<div className="text-xs text-gray-400 mt-0.5">Выручка за 30 дней</div>
<div className="text-xs text-gray-500 mt-1">{data.revenue.paid_count} платежей итого {data.revenue.total_rub.toLocaleString('ru-RU')}</div>
</div>
<div className="card p-4 border-red-500/20 bg-red-500/5">
<div className="text-2xl font-bold text-red-400">{data.ai.cost_rub.toFixed(2)}</div>
<div className="text-xs text-gray-400 mt-0.5">Расходы на AI за 30 дней</div>
<div className="text-xs text-gray-500 mt-1">
{data.ai.calls} запросов {data.ai.errors} ошибок
</div>
</div>
</div>
</div>
{/* Черновики */}
{data.drafts.pending > 0 && (
<div className="card p-4 border-accent/30 bg-accent/5 flex items-center justify-between">
<div>
<div className="font-medium text-sm"> {data.drafts.pending} черновиков ждут одобрения</div>
<div className="text-xs text-gray-400 mt-0.5">Просмотрите и запланируйте публикацию</div>
</div>
<a href="/drafts" className="btn-primary text-sm px-3 py-1.5">Смотреть </a>
</div>
)}
{/* Регистрации 14 дней */}
{data.registrations_14d.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Регистрации последние 14 дней</p>
<div className="card p-4">
<div className="flex items-end gap-1 h-16">
{(() => {
const max = Math.max(...data.registrations_14d.map(r => r.cnt), 1);
// Заполняем пустые дни
const days = [];
for (let i = 13; i >= 0; i--) {
const d = new Date(); d.setDate(d.getDate() - i);
const key = d.toISOString().split('T')[0];
const found = data.registrations_14d.find(r => r.day === key);
days.push({ day: key, cnt: found?.cnt || 0 });
}
return days.map((r, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full bg-accent/20 rounded-sm transition-all"
style={{ height: `${Math.max(2, (r.cnt / max) * 56)}px` }}
title={`${r.day}: ${r.cnt}`} />
{i % 7 === 6 && <div className="text-xs text-gray-600 whitespace-nowrap">
{new Date(r.day).toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })}
</div>}
</div>
));
})()}
</div>
</div>
</div>
)}
</>)}
</div>
);
}