diff --git a/app/api/admin/credit/route.js b/app/api/admin/credit/route.js new file mode 100644 index 0000000..e5a25f8 --- /dev/null +++ b/app/api/admin/credit/route.js @@ -0,0 +1,17 @@ +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 POST(req) { + const user = await requireUser(); + if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const body = await req.json(); + const res = await fetch(`${ENGINE_URL}/api/admin/credit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) }, + body: JSON.stringify(body), + }); + return NextResponse.json(await res.json()); +} diff --git a/app/api/admin/users/[id]/route.js b/app/api/admin/users/[id]/route.js new file mode 100644 index 0000000..d157752 --- /dev/null +++ b/app/api/admin/users/[id]/route.js @@ -0,0 +1,25 @@ +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 || ''; +const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) }); + +export async function GET(req, { params }) { + const user = await requireUser(); + if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const res = await fetch(`${ENGINE_URL}/api/admin/users/${params.id}`, { headers: h(user.id) }); + return NextResponse.json(await res.json()); +} + +export async function PATCH(req, { params }) { + const user = await requireUser(); + if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + const body = await req.json(); + const res = await fetch(`${ENGINE_URL}/api/admin/users/${params.id}`, { + method: 'PATCH', + headers: { ...h(user.id), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return NextResponse.json(await res.json()); +} diff --git a/components/AdminPanel.js b/components/AdminPanel.js index a3783ba..ca20540 100644 --- a/components/AdminPanel.js +++ b/components/AdminPanel.js @@ -3,6 +3,7 @@ import { useState } from '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'; +import AdminUsers from './admin/AdminUsers'; // ────────────────────────────────────────────────────────────── // Sidebar navigation @@ -63,7 +64,7 @@ export default function AdminPanel({ initialSection = 'settings' }) { {section === 'payments' && } {section === 'spending' && } {section === 'plans' && } - {section === 'billing' && } + {section === 'billing' && } diff --git a/components/admin/AdminUsers.js b/components/admin/AdminUsers.js new file mode 100644 index 0000000..d8dfe5a --- /dev/null +++ b/components/admin/AdminUsers.js @@ -0,0 +1,333 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { Search, RefreshCw, Loader2, Plus, ChevronRight, X, Check, + Ban, Unlock, CreditCard, User, ArrowLeft } from 'lucide-react'; + +const PLAN_BADGE = { + free: 'bg-gray-600 text-gray-200', + starter: 'bg-blue-600 text-white', + pro: 'bg-purple-600 text-white', + business: 'bg-yellow-600 text-black', +}; + +const PLATFORM_ICONS = { telegram: '✈️', vk: '🔵', max: '🟣' }; + +const TX_LABELS = { + spend_image: { icon: '🖼', color: 'text-red-400' }, + spend_text_post: { icon: '✍️', color: 'text-red-400' }, + spend_article: { icon: '📝', color: 'text-red-400' }, + plan_credit: { icon: '🎁', color: 'text-green-400' }, + topup: { icon: '💳', color: 'text-green-400' }, + bonus: { icon: '⭐', color: 'text-blue-400' }, +}; + +export default function AdminUsers() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [selected, setSelected]= useState(null); // детальный просмотр + const [detail, setDetail] = useState(null); + const [detailLoading, setDL] = useState(false); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(''); + // Credit form + const [creditAmount, setCreditAmount] = useState(''); + const [creditDesc, setCreditDesc] = useState(''); + const [showCredit, setShowCredit] = useState(false); + // Plan change + const [planOptions, setPlanOptions] = useState([]); + const [showPlan, setShowPlan] = useState(false); + const [newPlan, setNewPlan] = useState(''); + + async function loadUsers() { + setLoading(true); + try { + const [usersRes, plansRes] = await Promise.all([ + fetch('/api/admin/users').then(r => r.json()), + fetch('/api/billing/plans').then(r => r.json()), + ]); + setUsers(Array.isArray(usersRes) ? usersRes : []); + setPlanOptions(plansRes.plans || []); + } catch {} + setLoading(false); + } + + async function loadDetail(id) { + setDL(true); + try { + const res = await fetch(`/api/admin/users/${id}`).then(r => r.json()); + setDetail(res); + } catch {} + setDL(false); + } + + useEffect(() => { loadUsers(); }, []); + + async function toggleBlock(userId, currentBlocked) { + setSaving(true); + await fetch(`/api/admin/users/${userId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_blocked: !currentBlocked }), + }); + setMsg(currentBlocked ? 'Разблокирован' : 'Заблокирован'); + setTimeout(() => setMsg(''), 2000); + loadUsers(); + if (detail) loadDetail(userId); + setSaving(false); + } + + async function applyCredit(userId) { + if (!creditAmount) return; + setSaving(true); + await fetch('/api/admin/credit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_id: userId, amount: parseInt(creditAmount), description: creditDesc || undefined }), + }); + setMsg(`+${creditAmount} кредитов начислено`); + setTimeout(() => setMsg(''), 2000); + setCreditAmount(''); setCreditDesc(''); setShowCredit(false); + loadUsers(); + if (detail) loadDetail(userId); + setSaving(false); + } + + async function applyPlan(userId) { + if (!newPlan) return; + setSaving(true); + await fetch(`/api/admin/users/${userId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ plan_code: newPlan }), + }); + setMsg('План изменён'); + setTimeout(() => setMsg(''), 2000); + setShowPlan(false); setNewPlan(''); + loadUsers(); + if (detail) loadDetail(userId); + setSaving(false); + } + + const filtered = users.filter(u => + !search || u.email?.toLowerCase().includes(search.toLowerCase()) || u.name?.toLowerCase().includes(search.toLowerCase()) + ); + + // ── Детальная страница пользователя ── + if (selected && detail) { + const u = detail.user; + return ( +
+
+ + {msg && {msg}} +
+ + {/* Профиль */} +
+
+
+
+ +
+
+
{u.name || u.email}
+ {u.name &&
{u.email}
} +
+ Зарегистрирован: {new Date(u.created_at).toLocaleDateString('ru-RU')} + {u.is_admin && ' · 👑 Admin'} + {u.is_blocked && ' · 🚫 Заблокирован'} +
+
+
+
+ + + +
+
+ + {/* Форма начисления кредитов */} + {showCredit && ( +
+ setCreditAmount(e.target.value)} + placeholder="Кол-во" className="input py-1.5 text-sm w-24" autoFocus /> + setCreditDesc(e.target.value)} + placeholder="Комментарий" className="input py-1.5 text-sm flex-1 min-w-40" /> + + +
+ )} + + {/* Форма смены тарифа */} + {showPlan && ( +
+ + + +
+ )} +
+ + {/* Баланс */} + {detail.balance && ( +
+
+
{detail.balance.credits ?? 0}
+
кредитов
+
+
+
+ {detail.balance.plan_name || 'Free'} +
+
тариф
+
+
+
{detail.balance.reset_at ? new Date(detail.balance.reset_at).toLocaleDateString('ru-RU') : '—'}
+
сброс кредитов
+
+
+ )} + + {/* Каналы */} + {detail.channels.length > 0 && ( +
+

Каналы ({detail.channels.length})

+
+ {detail.channels.map(ch => ( +
+ {PLATFORM_ICONS[ch.platform] || '📢'} + {ch.name} + {ch.tg_username && @{ch.tg_username}} + + {ch.is_active ? 'активен' : 'выкл'} + +
+ ))} +
+
+ )} + + {/* История транзакций */} + {detail.transactions.length > 0 && ( +
+
История транзакций
+ {detail.transactions.map(tx => { + const meta = TX_LABELS[tx.type] || { icon: '💬', color: 'text-gray-400' }; + return ( +
+
+ {meta.icon} + {tx.description || tx.type} +
+
+
{tx.amount > 0 ? '+' : ''}{tx.amount} кр
+
{new Date(tx.created_at).toLocaleDateString('ru-RU')}
+
+
+ ); + })} +
+ )} +
+ ); + } + + if (selected && detailLoading) { + return
; + } + + // ── Список пользователей ── + return ( +
+
+

Пользователи

+
+
+ + setSearch(e.target.value)} + placeholder="Поиск по email..." + className="input pl-8 py-1.5 text-sm w-52" /> +
+ +
+
+ + {msg &&
{msg}
} + {loading &&
} + + {!loading && ( +
+ + + + + + + + + + + + + {filtered.map(u => ( + + + + + + + + + ))} + {!filtered.length && ( + + )} + +
ПользовательТарифКредитыСтатусЗарегистрированДействия
+
{u.name || u.email}
+ {u.name &&
{u.email}
} + {u.is_admin && 👑 admin} +
+ + {u.plan_name || 'Free'} + + {u.credits ?? 0} + {u.is_blocked + ? 🚫 Блок + : ✓ Активен} + + {new Date(u.created_at).toLocaleDateString('ru-RU')} + + +
Пользователи не найдены
+
+ )} +
+ ); +}