feat: unified admin panel + back buttons everywhere

AdminPanel.js: sidebar nav с 4 разделами (Настройки API, ЮKassa, Расходы AI, Пользователи)
  Встроены: SettingsSection (API-ключи), SpendingSection (расходы), AdminBilling
  Breadcrumb навигация
/system/page.js: теперь рендерит AdminPanel
Header: 'Расходы' → 'Админ' (ссылка на /system), убран TrendingUp
BackButton.js: переиспользуемая кнопка назад
  Добавлена на /drafts, /billing, /plans
This commit is contained in:
Ник (Claude)
2026-06-12 23:57:38 +03:00
parent d888816f2b
commit 1fbdc9f9b9
7 changed files with 369 additions and 15 deletions
+338
View File
@@ -0,0 +1,338 @@
'use client';
import { useState } from 'react';
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import AdminBilling from './admin/AdminBilling';
// ──────────────────────────────────────────────────────────────
// Sidebar navigation
// ──────────────────────────────────────────────────────────────
const SECTIONS = [
{ id: 'settings', label: 'Настройки API', icon: Settings2, desc: 'AI-провайдеры, поиск фото' },
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
];
export default function AdminPanel({ initialSection = 'settings' }) {
const [section, setSection] = useState(initialSection);
return (
<div className="max-w-6xl mx-auto p-4 sm:p-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-6 text-sm text-gray-500">
<Link href="/" className="hover:text-gray-200 flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" /> Главная
</Link>
<ChevronRight className="w-3.5 h-3.5" />
<span className="text-gray-200">Администрирование</span>
<ChevronRight className="w-3.5 h-3.5" />
<span className="text-accent">{SECTIONS.find(s => s.id === section)?.label}</span>
</div>
<div className="flex gap-6">
{/* Sidebar */}
<aside className="w-52 shrink-0">
<div className="card p-2 space-y-0.5 sticky top-6">
<p className="text-xs text-gray-500 uppercase tracking-wide px-3 py-2 font-medium">Разделы</p>
{SECTIONS.map(({ id, label, icon: Icon, desc }) => (
<button key={id} onClick={() => setSection(id)}
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors flex items-center gap-3 ${
section === id
? 'bg-accent/10 text-accent'
: 'hover:bg-surface2 text-gray-300'
}`}>
<Icon className="w-4 h-4 shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{label}</div>
<div className="text-xs text-gray-500 truncate">{desc}</div>
</div>
</button>
))}
</div>
</aside>
{/* Content */}
<div className="flex-1 min-w-0">
{section === 'settings' && <SettingsSection categories={['ai_providers', 'photo_search']} />}
{section === 'payments' && <SettingsSection categories={['payments']} />}
{section === 'spending' && <SpendingSection />}
{section === 'billing' && <AdminBilling />}
</div>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Settings section (API keys)
// ──────────────────────────────────────────────────────────────
const CATEGORY_META = {
ai_providers: {
title: 'AI-провайдеры',
hint: 'Ключи и URL для текстовой и картиночной генерации. Меняются на лету.',
},
photo_search: {
title: 'Поиск фото',
hint: 'Yandex Search API: ключ и folder.',
},
payments: {
title: 'ЮKassa',
hint: 'Shop ID и Secret Key из личного кабинета. Webhook: https://engine.zeropost.ru/api/billing/webhook',
},
};
function SettingsSection({ categories }) {
const [data, setData] = useState({});
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
async function load() {
setLoading(true);
const all = {};
for (const cat of categories) {
try {
const rows = await fetch(`/api/admin/settings?category=${cat}`).then(r => r.json());
all[cat] = Array.isArray(rows) ? rows : [];
} catch { all[cat] = []; }
}
setData(all);
setLoaded(true);
setLoading(false);
}
if (!loaded && !loading) { load(); }
return (
<div className="space-y-6">
{categories.map(cat => {
const meta = CATEGORY_META[cat] || { title: cat, hint: '' };
const rows = data[cat] || [];
return (
<section key={cat} className="card p-5">
<div className="mb-4">
<h2 className="font-semibold">{meta.title}</h2>
{meta.hint && <p className="text-xs text-gray-500 mt-1">{meta.hint}</p>}
</div>
{loading && !rows.length
? <div className="py-4 text-center"><Loader2 className="w-4 h-4 animate-spin mx-auto" /></div>
: rows.map(row => (
<SettingRow key={row.key} row={row} onSaved={load} />
))
}
</section>
);
})}
</div>
);
}
function SettingRow({ row, onSaved }) {
const [val, setVal] = useState(row.value || '');
const [show, setShow] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState(null); // 'ok' | 'error'
const [errMsg, setErrMsg] = useState('');
const dirty = val !== (row.value || '');
async function save() {
setSaving(true); setStatus(null);
try {
const res = await fetch(`/api/admin/settings/${encodeURIComponent(row.key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: val }),
}).then(r => r.json());
if (res.error) { setStatus('error'); setErrMsg(res.error); }
else { setStatus('ok'); onSaved(); setTimeout(() => setStatus(null), 2000); }
} catch (e) { setStatus('error'); setErrMsg(e.message); }
setSaving(false);
}
const isSecret = row.is_secret;
const inputType = isSecret && !show ? 'password' : 'text';
return (
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<label className="label text-xs">{row.key}</label>
<span className="text-xs text-gray-600">{row.category}</span>
</div>
{row.description && <p className="text-xs text-gray-500 mb-1.5">{row.description}</p>}
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={inputType}
value={val}
onChange={e => setVal(e.target.value)}
className="input w-full pr-8 font-mono text-sm"
placeholder={isSecret ? '••••••••' : 'Введите значение...'}
/>
{isSecret && (
<button onClick={() => setShow(s => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300">
{show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
)}
</div>
{dirty && (
<button onClick={save} disabled={saving}
className="btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{status === 'ok' && <Check className="w-5 h-5 text-green-400 self-center shrink-0" />}
{status === 'error' && <AlertCircle className="w-5 h-5 text-red-400 self-center shrink-0" title={errMsg} />}
</div>
<div className="text-xs text-gray-600 mt-1">
обновлено: {row.updated_at ? new Date(row.updated_at).toLocaleString('ru-RU') : '—'}
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Spending section
// ──────────────────────────────────────────────────────────────
const PERIODS = [
{ v: 'today', label: 'Сегодня' },
{ v: 'week', label: '7 дней' },
{ v: 'month', label: '30 дней' },
{ v: 'alltime', label: 'Всё время' },
];
const TYPE_LABELS = {
'chat': '💬 Текст',
'image': '🖼 Изображение',
'image_via_responses': '🖼 Изображение',
'article': '📝 Статья',
'topic': '🔍 Топики',
};
function fmt(n) { return Number(n || 0).toFixed(2); }
function fmtI(n) { return Number(n || 0).toLocaleString('ru-RU'); }
function SpendingSection() {
const [period, setPeriod] = useState('month');
const [data, setData] = useState(null);
const [byProv, setByProv] = useState(null);
const [loading, setLoading] = useState(false);
async function load(p) {
setLoading(true);
try {
const [r1, r2] = await Promise.all([
fetch(`/api/usage/summary?range=${p}&group_by=request_type`).then(r => r.json()),
fetch(`/api/usage/summary?range=${p}&group_by=provider`).then(r => r.json()),
]);
setData(r1); setByProv(r2);
} catch {}
setLoading(false);
}
if (!data && !loading) load(period);
const totals = data?.totals || {};
const aiprimetech = byProv?.breakdown?.find(b => b.key === 'aiprimetech');
const routerai = byProv?.breakdown?.find(b => b.key === 'routerai');
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Расходы на AI</h2>
<div className="flex gap-1">
{PERIODS.map(p => (
<button key={p.v} onClick={() => { setPeriod(p.v); load(p.v); }}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${period === p.v ? 'bg-accent text-white' : 'btn-ghost'}`}>
{p.label}
</button>
))}
<button onClick={() => load(period)} className="btn-ghost p-1.5 ml-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && data && (<>
{/* Итого */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Итого, ₽', value: `${fmt(totals.cost_rub)}`, accent: true },
{ label: 'Запросов', value: fmtI(totals.calls) },
{ label: 'Токенов', value: fmtI((totals.prompt_tokens||0)+(totals.completion_tokens||0)) },
{ label: 'Картинок', value: fmtI(totals.image_count) },
].map(s => (
<div key={s.label} className={`card p-3 ${s.accent ? 'border-accent/40 bg-accent/5' : ''}`}>
<div className={`text-xl font-bold ${s.accent ? 'text-accent' : ''}`}>{s.value}</div>
<div className="text-xs text-gray-500 mt-0.5">{s.label}</div>
</div>
))}
</div>
{/* По провайдерам */}
<div className="grid grid-cols-2 gap-3">
{[
{ key: 'aiprimetech', label: 'aiprimetech.io', icon: '💬', desc: 'Текст', data: aiprimetech },
{ key: 'routerai', label: 'routerai.ru', icon: '🖼', desc: 'Картинки', data: routerai },
].map(p => (
<div key={p.key} className="card p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{p.icon}</span>
<div>
<div className="font-medium text-sm">{p.label}</div>
<div className="text-xs text-gray-500">{p.desc}</div>
</div>
<div className="ml-auto text-accent font-bold"> {fmt(p.data?.cost_rub)}</div>
</div>
<div className="grid grid-cols-3 gap-1 text-xs text-gray-400">
<div><div className="font-medium text-gray-200">{fmtI(p.data?.calls)}</div>запросов</div>
<div><div className="font-medium text-red-400">{p.data?.failed||0}</div>ошибок</div>
<div><div className="font-medium text-gray-200">{fmtI(p.data?.image_count || (p.data?.prompt_tokens||0)+(p.data?.completion_tokens||0))}</div>{p.key==='routerai'?'картинок':'токенов'}</div>
</div>
</div>
))}
</div>
{/* Таблица по операциям */}
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface2 text-xs text-gray-400">
<tr>
<th className="px-4 py-2.5 text-left">Операция</th>
<th className="px-4 py-2.5 text-right">Запросов</th>
<th className="px-4 py-2.5 text-right">Ошибок</th>
<th className="px-4 py-2.5 text-right">Объём</th>
<th className="px-4 py-2.5 text-right">Стоимость</th>
</tr>
</thead>
<tbody>
{(data.breakdown || []).map((row, i) => (
<tr key={i} className="border-t border-border hover:bg-surface2/50">
<td className="px-4 py-2.5">{TYPE_LABELS[row.key] || row.key}</td>
<td className="px-4 py-2.5 text-right text-gray-300">{fmtI(row.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400">{row.failed || 0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs">
{row.image_count > 0 ? `${row.image_count} шт.` : fmtI((row.prompt_tokens||0)+(row.completion_tokens||0))}
</td>
<td className="px-4 py-2.5 text-right font-medium"> {fmt(row.cost_rub)}</td>
</tr>
))}
</tbody>
<tfoot className="bg-surface2 border-t border-border">
<tr>
<td className="px-4 py-2.5 font-semibold">Итого</td>
<td className="px-4 py-2.5 text-right font-semibold">{fmtI(totals.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400 font-semibold">{totals.failed||0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs"></td>
<td className="px-4 py-2.5 text-right font-bold text-accent"> {fmt(totals.cost_rub)}</td>
</tr>
</tfoot>
</table>
</div>
</>)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
'use client';
import { useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
export default function BackButton({ href, label = 'Назад' }) {
const router = useRouter();
function go() {
if (href) router.push(href);
else if (window.history.length > 1) router.back();
else router.push('/');
}
return (
<button onClick={go} className="btn-ghost flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-200 mb-4 -ml-1">
<ArrowLeft className="w-4 h-4" />
{label}
</button>
);
}
+4 -4
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Sparkles, LogOut, Settings2, CalendarDays, TrendingUp, Coins, FileText } from 'lucide-react';
import { Sparkles, LogOut, Settings2, CalendarDays, Coins, FileText } from 'lucide-react';
import ThemeToggle from './ThemeToggle';
export default function Header({ user }) {
@@ -39,9 +39,9 @@ export default function Header({ user }) {
<span>Черновики</span>
</Link>
{user?.isAdmin && (
<Link href="/spending" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
<TrendingUp className="w-4 h-4" />
<span>Расходы</span>
<Link href="/system" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
<Settings2 className="w-4 h-4" />
<span>Админ</span>
</Link>
)}
</nav>