forked from admin/zeropost-tool
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:
@@ -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
@@ -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'} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import AdminBilling from './admin/AdminBilling';
|
|||||||
// Sidebar navigation
|
// Sidebar navigation
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
|
{ id: 'dashboard', label: 'Сводка', icon: BarChart3, desc: 'Пользователи, посты, финансы' },
|
||||||
{ id: 'settings', label: 'AI-провайдеры', icon: Settings2, desc: 'Ключи aiprimetech и routerai' },
|
{ id: 'settings', label: 'AI-провайдеры', icon: Settings2, desc: 'Ключи aiprimetech и routerai' },
|
||||||
{ id: 'engine', label: 'Движок', icon: Zap, desc: 'URL, Telegram, авто-черновики' },
|
{ id: 'engine', label: 'Движок', icon: Zap, desc: 'URL, Telegram, авто-черновики' },
|
||||||
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user