forked from admin/zeropost-tool
2e9f099b95
AdminContent.js: настройки дефолтов по 4 группам Контент: язык, стиль, цель, длина (select) Форматирование: изображения, эмодзи, хештеги (toggle) Авто-черновики: кол-во в день, время генерации AI-инструкции: базовый промт (textarea) Инлайн сохранение — кнопка Сохранить появляется только при изменении Подсказка: изменения применяются только к новым каналам AdminPanel: раздел Контент-дефолты с Sliders иконкой
629 lines
30 KiB
JavaScript
629 lines
30 KiB
JavaScript
'use client';
|
||
import { useState } from 'react';
|
||
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders } from 'lucide-react';
|
||
import Link from 'next/link';
|
||
import AdminBilling from './admin/AdminBilling';
|
||
import AdminUsers from './admin/AdminUsers';
|
||
import AdminPromos from './admin/AdminPromos';
|
||
import AdminQueue from './admin/AdminQueue';
|
||
import AdminLogs from './admin/AdminLogs';
|
||
import AdminAutogen from './admin/AdminAutogen';
|
||
import AdminContent from './admin/AdminContent';
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Sidebar navigation
|
||
// ──────────────────────────────────────────────────────────────
|
||
const SECTIONS = [
|
||
{ id: 'dashboard', label: 'Сводка', icon: BarChart3, desc: 'Пользователи, посты, финансы' },
|
||
{ id: 'settings', label: 'AI-провайдеры', icon: Settings2, desc: 'Ключи aiprimetech и routerai' },
|
||
{ id: 'engine', label: 'Движок', icon: Zap, desc: 'URL, Telegram, авто-черновики' },
|
||
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
||
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
|
||
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
|
||
{ id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' },
|
||
{ id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' },
|
||
{ id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' },
|
||
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
|
||
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
|
||
{ 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 === 'dashboard' && <DashboardSection />}
|
||
{section === 'settings' && <SettingsSection categories={['ai_providers', 'photo_search']} />}
|
||
{section === 'engine' && <SettingsSection categories={['engine']} />}
|
||
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
||
{section === 'spending' && <SpendingSection />}
|
||
{section === 'queue' && <AdminQueue />}
|
||
{section === 'logs' && <AdminLogs />}
|
||
{section === 'autogen' && <AdminAutogen />}
|
||
{section === 'content' && <AdminContent />}
|
||
{section === 'plans' && <PlansSection />}
|
||
{section === 'promos' && <AdminPromos />}
|
||
{section === 'billing' && <AdminUsers />}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// Plans & Credits section
|
||
// ──────────────────────────────────────────────────────────────
|
||
function PlansSection() {
|
||
const [plans, setPlans] = useState([]);
|
||
const [costs, setCosts] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState({});
|
||
const [msg, setMsg] = useState('');
|
||
|
||
async function load() {
|
||
setLoading(true);
|
||
try {
|
||
const res = await fetch('/api/billing/plans').then(r => r.json());
|
||
setPlans(res.plans || []);
|
||
setCosts(res.costs || []);
|
||
} catch {}
|
||
setLoading(false);
|
||
}
|
||
|
||
async function savePlan(plan) {
|
||
setSaving(s => ({ ...s, [plan.id]: true }));
|
||
try {
|
||
await fetch(`/api/admin/plans/${plan.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ price_rub: plan.price_rub, credits_month: plan.credits_month, channels_max: plan.channels_max }),
|
||
});
|
||
setMsg('Сохранено ✓');
|
||
setTimeout(() => setMsg(''), 2000);
|
||
} catch {}
|
||
setSaving(s => ({ ...s, [plan.id]: false }));
|
||
}
|
||
|
||
async function saveCost(cost) {
|
||
setSaving(s => ({ ...s, [`cost_${cost.operation}`]: true }));
|
||
try {
|
||
await fetch(`/api/admin/credit-costs/${cost.operation}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ credits: cost.credits }),
|
||
});
|
||
setMsg('Сохранено ✓');
|
||
setTimeout(() => setMsg(''), 2000);
|
||
} catch {}
|
||
setSaving(s => ({ ...s, [`cost_${cost.operation}`]: false }));
|
||
}
|
||
|
||
if (loading && !plans.length) { load(); }
|
||
|
||
const PLAN_LABELS = { free: 'Free', starter: 'Starter', pro: 'Pro', business: 'Business' };
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{msg && <div className="text-sm text-green-400 flex items-center gap-1"><Check className="w-4 h-4" />{msg}</div>}
|
||
|
||
{/* Тарифные планы */}
|
||
<section className="card p-5">
|
||
<h2 className="font-semibold mb-1">Тарифные планы</h2>
|
||
<p className="text-xs text-gray-500 mb-4">Цены и лимиты. -1 = безлимит.</p>
|
||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : (
|
||
<div className="space-y-3">
|
||
{plans.map(plan => {
|
||
const [p, setP] = [plan, (updates) => setPlans(pp => pp.map(x => x.id === plan.id ? { ...x, ...updates } : x))];
|
||
return (
|
||
<div key={p.id} className="flex items-center gap-3 p-3 rounded-lg bg-surface2">
|
||
<div className="w-20 font-medium text-sm">{PLAN_LABELS[p.code] || p.code}</div>
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-xs text-gray-500">₽/мес:</span>
|
||
<input type="number" value={p.price_rub} onChange={e => setP({ price_rub: +e.target.value })}
|
||
className="input w-20 text-sm py-1" />
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-xs text-gray-500">кредитов:</span>
|
||
<input type="number" value={p.credits_month} onChange={e => setP({ credits_month: +e.target.value })}
|
||
className="input w-20 text-sm py-1" />
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-xs text-gray-500">каналов:</span>
|
||
<input type="number" value={p.channels_max} onChange={e => setP({ channels_max: +e.target.value })}
|
||
className="input w-16 text-sm py-1" />
|
||
</div>
|
||
<button onClick={() => savePlan(p)} disabled={saving[p.id]}
|
||
className="btn-primary text-xs px-2.5 py-1.5 ml-auto flex items-center gap-1">
|
||
{saving[p.id] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Стоимость операций */}
|
||
<section className="card p-5">
|
||
<h2 className="font-semibold mb-1">Стоимость операций (кредиты)</h2>
|
||
<p className="text-xs text-gray-500 mb-4">Сколько кредитов списывается за каждую операцию.</p>
|
||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : (
|
||
<div className="space-y-2">
|
||
{costs.map(cost => {
|
||
const [c, setC] = [cost, (updates) => setCosts(cc => cc.map(x => x.operation === cost.operation ? { ...x, ...updates } : x))];
|
||
const icons = { image: '🖼', text_post: '✍️', article: '📝', autopublish: '📤' };
|
||
return (
|
||
<div key={c.operation} className="flex items-center gap-3 p-3 rounded-lg bg-surface2">
|
||
<span className="text-lg w-7 text-center">{icons[c.operation] || '⚙️'}</span>
|
||
<div className="flex-1 text-sm">{c.description || c.operation}</div>
|
||
<div className="flex items-center gap-2">
|
||
<input type="number" value={c.credits} onChange={e => setC({ credits: +e.target.value })}
|
||
className="input w-16 text-sm py-1 text-center" min={0} />
|
||
<span className="text-xs text-gray-500">кр</span>
|
||
</div>
|
||
<button onClick={() => saveCost(c)} disabled={saving[`cost_${c.operation}`]}
|
||
className="btn-primary text-xs px-2.5 py-1.5 flex items-center gap-1">
|
||
{saving[`cost_${c.operation}`] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</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>
|
||
);
|
||
}
|