'use client';
import { useState, useEffect } from 'react';
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders, Mail } 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';
import AdminTopicBank from './admin/AdminTopicBank';
// ──────────────────────────────────────────────────────────────
// 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: 'topicbank', label: 'Банк тем блога', icon: BookOpen, desc: 'Темы для postcast.ru' },
{ id: 'smtp', label: 'Email / SMTP', icon: Mail, 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 (
{/* Breadcrumb */}
Главная
Администрирование
{SECTIONS.find(s => s.id === section)?.label}
{/* Sidebar */}
{/* Content */}
{section === 'dashboard' &&
}
{section === 'settings' &&
}
{section === 'engine' &&
}
{section === 'payments' &&
}
{section === 'spending' &&
}
{section === 'queue' &&
}
{section === 'logs' &&
}
{section === 'autogen' &&
}
{section === 'content' &&
}
{section === 'topicbank' &&
}
{section === 'smtp' &&
} />}
{section === 'plans' &&
}
{section === 'promos' &&
}
{section === 'billing' &&
}
);
}
// ──────────────────────────────────────────────────────────────
// 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.postcast.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);
}
useEffect(() => { load(); }, []);
return (
{categories.map(cat => {
const meta = CATEGORY_META[cat] || { title: cat, hint: '' };
const rows = data[cat] || [];
return (
{meta.title}
{meta.hint &&
{meta.hint}
}
{loading && !rows.length
?
: rows.map(row => (
))
}
);
})}
);
}
const SETTING_LABELS = {
AI_IMAGE_API_KEY: 'API ключ картинок',
AI_IMAGE_BASE_URL: 'Base URL картинок',
AI_IMAGE_FALLBACK_API_KEY: 'Fallback — API ключ',
AI_IMAGE_FALLBACK_BASE_URL: 'Fallback — Base URL',
AI_IMAGE_MODEL: 'Модель картинок',
AI_IMAGE_MODEL_VIA_RESPONSES: 'Модель картинок (responses)',
AI_PROVIDER_MARKUP: 'Наценка провайдера',
AI_TEXT_API_KEY: 'API ключ текста',
AI_TEXT_BASE_URL: 'Base URL провайдера',
AI_TEXT_MODEL_ARTICLE: 'Модель для статей',
AI_TEXT_MODEL_POST: 'Модель для постов',
AI_TEXT_MODEL_TOPICS: 'Модель для тем',
AI_USD_RUB_RATE: 'Курс USD → RUB',
ROUTERAI_API_KEY: 'RouterAI — API ключ',
ROUTERAI_BASE_URL: 'RouterAI — Base URL',
ROUTERAI_IMAGE_MODEL: 'RouterAI — модель',
APP_PUBLIC_URL: 'URL приложения',
ENGINE_PUBLIC_URL: 'URL движка',
TELEGRAM_API_BASE: 'Telegram Bot API',
MAINTENANCE_MODE: 'Режим обслуживания',
MAINTENANCE_MESSAGE: 'Сообщение при обслуживании',
AUTO_DRAFT_DEFAULT_COUNT: 'Черновиков в день',
AUTO_DRAFT_DEFAULT_TIME: 'Время генерации',
YUKASSA_SHOP_ID: 'ID магазина',
YUKASSA_SECRET: 'Секретный ключ',
YUKASSA_RETURN_URL: 'URL возврата',
SMTP_ENABLED: 'Email включён',
SMTP_FROM: 'Email отправителя',
SMTP_HOST: 'SMTP сервер',
SMTP_PASS: 'SMTP пароль',
SMTP_PORT: 'SMTP порт',
SMTP_USER: 'SMTP логин',
PHOTO_SEARCH_PROVIDER: 'Провайдер поиска',
YANDEX_SEARCH_API_KEY: 'Яндекс — API ключ',
YANDEX_SEARCH_DAILY_LIMIT: 'Лимит запросов/день',
YANDEX_SEARCH_FOLDER_ID: 'Яндекс — Folder ID',
};
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 (
{row.category}
{row.description &&
{row.description}
}
setVal(e.target.value)}
className="input w-full pr-8 font-mono text-sm"
placeholder={isSecret ? '••••••••' : 'Введите значение...'}
/>
{isSecret && (
)}
{dirty && (
)}
{status === 'ok' &&
}
{status === 'error' &&
}
обновлено: {row.updated_at ? new Date(row.updated_at).toLocaleString('ru-RU') : '—'}
);
}
// ──────────────────────────────────────────────────────────────
// Spending section
// ──────────────────────────────────────────────────────────────
const PERIODS = [
{ v: 'today', label: 'Сегодня' },
{ v: 'week', label: '7 дней' },
{ v: 'month', label: '30 дней' },
{ v: 'all', 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('all');
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 === 'alltime' ? 'all' : p}&group_by=request_type`).then(r => r.json()),
fetch(`/api/usage/summary?range=${p === 'alltime' ? 'all' : p}&group_by=provider`).then(r => r.json()),
]);
setData(r1); setByProv(r2);
} catch {}
setLoading(false);
}
useEffect(() => { 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 (
Расходы на AI
{PERIODS.map(p => (
))}
{loading &&
}
{!loading && data && (<>
{/* Итого */}
{[
{ 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 => (
))}
{/* 2 отдельных блока — aiprimetech и routerai */}
{/* aiprimetech — текст */}
{(() => {
const d = byProv?.breakdown?.find(b => b.key === 'aiprimetech');
const rub = Number(d?.cost_rub || 0);
return (
💬
aiprimetech.io
Текст — статьи и посты
₽ {fmt(rub)}
{fmtI(d?.calls)}
запросов
0 ? 'text-red-400' : 'text-gray-200'}`}>{d?.failed||0}
ошибок
{fmtI((d?.prompt_tokens||0)+(d?.completion_tokens||0))}
токенов
);
})()}
{/* routerai — картинки */}
{(() => {
const d = byProv?.breakdown?.find(b => b.key === 'routerai');
const rub = Number(d?.cost_rub || 0);
return (
🖼
routerai.ru
Картинки к постам
₽ {fmt(rub)}
{fmtI(d?.calls)}
запросов
0 ? 'text-red-400' : 'text-gray-200'}`}>{d?.failed||0}
ошибок
{fmtI(d?.image_count||0)}
картинок
);
})()}
{/* Таблица по операциям */}
| Операция |
Запросов |
Ошибок |
Объём |
Стоимость |
{(data.breakdown || []).map((row, i) => (
| {TYPE_LABELS[row.key] || row.key} |
{fmtI(row.calls)} |
{row.failed || 0} |
{row.image_count > 0 ? `${row.image_count} шт.` : fmtI((row.prompt_tokens||0)+(row.completion_tokens||0))}
|
₽ {fmt(row.cost_rub)} |
))}
| Итого |
{fmtI(totals.calls)} |
{totals.failed||0} |
— |
₽ {fmt(totals.cost_rub)} |
>)}
);
}
// ──────────────────────────────────────────────────────────────
// 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 }));
}
useEffect(() => { load(); }, []);
const PLAN_LABELS = { free: 'Free', starter: 'Starter', pro: 'Pro', business: 'Business' };
return (
{msg &&
{msg}
}
{/* Тарифные планы */}
Тарифные планы
Цены и лимиты. -1 = безлимит.
{loading ? : (
{plans.map(plan => {
const [p, setP] = [plan, (updates) => setPlans(pp => pp.map(x => x.id === plan.id ? { ...x, ...updates } : x))];
return (
);
})}
)}
{/* Стоимость операций */}
Стоимость операций (кредиты)
Сколько кредитов списывается за каждую операцию.
{loading ? : (
{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 (
);
})}
)}
);
}
// ──────────────────────────────────────────────────────────────
// 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);
}
useEffect(() => { load(); }, []);
const PLATFORM_ICONS = { telegram: '✈️', vk: '🔵', max: '🟣' };
function Stat({ label, value, sub, accent }) {
return (
{value}
{label}
{sub &&
{sub}
}
);
}
return (
Сводка
{loading &&
}
{data && (<>
{/* Пользователи */}
{/* Каналы */}
Каналы
s + c.cnt, 0)} />
{data.channels.map(c => (
))}
{/* Посты */}
{/* Финансы */}
Финансы
₽{data.revenue.month_rub.toLocaleString('ru-RU')}
Выручка за 30 дней
{data.revenue.paid_count} платежей • итого ₽{data.revenue.total_rub.toLocaleString('ru-RU')}
₽{data.ai.cost_rub.toFixed(2)}
Расходы на AI за 30 дней
{data.ai.calls} запросов • {data.ai.errors} ошибок
{/* Черновики */}
{data.drafts.pending > 0 && (
⚡ {data.drafts.pending} черновиков ждут одобрения
Просмотрите и запланируйте публикацию
Смотреть →
)}
{/* Регистрации 14 дней */}
{data.registrations_14d.length > 0 && (
Регистрации — последние 14 дней
{(() => {
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) => (
{i % 7 === 6 &&
{new Date(r.day).toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })}
}
));
})()}
)}
>)}
);
}
// ── SMTP Test Button ──────────────────────────────────────────
function SmtpTestButton() {
const [email, setEmail] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
async function test() {
if (!email.trim()) return;
setBusy(true);
const res = await fetch('/api/admin/email/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: email }),
}).then(r => r.json());
setBusy(false);
setMsg(res.ok ? '✅ Письмо отправлено' : '❌ ' + (res.error || res.message));
setTimeout(() => setMsg(''), 5000);
}
return (
Тест отправки
setEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && test()}
type="email" placeholder="test@example.com"
className="input flex-1 text-sm py-1.5" />
{msg &&
{msg}
}
);
}