forked from admin/zeropost-tool
feat: admin panel improvements
Header: убрана кнопка Система (дубль Админ), убраны устаревшие импорты AdminPanel: 6 разделов (AI-провайдеры, Движок, ЮKassa, Расходы AI, Тарифы, Пользователи) Тарифы: редактор планов (цена/кредиты/каналы) + стоимость операций Движок: ENGINE_PUBLIC_URL, APP_PUBLIC_URL, TELEGRAM_API_BASE, AUTO_DRAFT_* PlansSection: inline-редактирование тарифов и credit_costs API routes: /api/admin/plans/[id], /api/admin/credit-costs/[operation]
This commit is contained in:
+133
-2
@@ -1,6 +1,6 @@
|
||||
'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 { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import AdminBilling from './admin/AdminBilling';
|
||||
|
||||
@@ -8,9 +8,11 @@ import AdminBilling from './admin/AdminBilling';
|
||||
// Sidebar navigation
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const SECTIONS = [
|
||||
{ id: 'settings', label: 'Настройки API', icon: Settings2, desc: 'AI-провайдеры, поиск фото' },
|
||||
{ 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: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
|
||||
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
|
||||
];
|
||||
|
||||
@@ -55,8 +57,10 @@ export default function AdminPanel({ initialSection = 'settings' }) {
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{section === 'settings' && <SettingsSection categories={['ai_providers', 'photo_search']} />}
|
||||
{section === 'engine' && <SettingsSection categories={['engine']} />}
|
||||
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
||||
{section === 'spending' && <SpendingSection />}
|
||||
{section === 'plans' && <PlansSection />}
|
||||
{section === 'billing' && <AdminBilling />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,3 +340,130 @@ function SpendingSection() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user