feat: AdminUsers — full user management UI

AdminUsers.js: список пользователей с поиском
  Детальная страница пользователя:
    - Профиль (email, дата рег, статус)
    - Баланс кредитов и тариф
    - Список каналов с платформами
    - История транзакций (20 последних)
    - Кнопки: начислить кредиты, сменить тариф, заблокировать/разблокировать
AdminPanel: billing раздел → AdminUsers (был AdminBilling)
API routes: /api/admin/users/[id] (GET+PATCH), /api/admin/credit (POST)
This commit is contained in:
Ник (Claude)
2026-06-13 00:14:59 +03:00
parent 92b743512c
commit e5f6662aed
4 changed files with 377 additions and 1 deletions
+2 -1
View File
@@ -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' && <SettingsSection categories={['payments']} />}
{section === 'spending' && <SpendingSection />}
{section === 'plans' && <PlansSection />}
{section === 'billing' && <AdminBilling />}
{section === 'billing' && <AdminUsers />}
</div>
</div>
</div>
+333
View File
@@ -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 (
<div className="space-y-5">
<div className="flex items-center gap-3">
<button onClick={() => { setSelected(null); setDetail(null); setShowCredit(false); setShowPlan(false); }}
className="btn-ghost flex items-center gap-1.5 text-sm">
<ArrowLeft className="w-4 h-4" /> Все пользователи
</button>
{msg && <span className="text-sm text-green-400">{msg}</span>}
</div>
{/* Профиль */}
<div className="card p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center">
<User className="w-5 h-5 text-accent" />
</div>
<div>
<div className="font-semibold">{u.name || u.email}</div>
{u.name && <div className="text-sm text-gray-400">{u.email}</div>}
<div className="text-xs text-gray-500 mt-0.5">
Зарегистрирован: {new Date(u.created_at).toLocaleDateString('ru-RU')}
{u.is_admin && ' · 👑 Admin'}
{u.is_blocked && ' · 🚫 Заблокирован'}
</div>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => setShowCredit(v => !v)}
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5" /> Кредиты
</button>
<button onClick={() => setShowPlan(v => !v)}
className="btn-ghost text-sm px-3 py-1.5 flex items-center gap-1.5">
<CreditCard className="w-3.5 h-3.5" /> Тариф
</button>
<button onClick={() => toggleBlock(u.id, u.is_blocked)} disabled={saving}
className={`text-sm px-3 py-1.5 rounded-lg flex items-center gap-1.5 transition-colors ${
u.is_blocked ? 'bg-green-600/20 text-green-400 hover:bg-green-600/30' : 'bg-red-600/20 text-red-400 hover:bg-red-600/30'
}`}>
{u.is_blocked ? <><Unlock className="w-3.5 h-3.5" /> Разблокировать</> : <><Ban className="w-3.5 h-3.5" /> Заблокировать</>}
</button>
</div>
</div>
{/* Форма начисления кредитов */}
{showCredit && (
<div className="mt-4 p-3 rounded-lg bg-accent/5 border border-accent/20 flex items-center gap-2 flex-wrap">
<input type="number" value={creditAmount} onChange={e => setCreditAmount(e.target.value)}
placeholder="Кол-во" className="input py-1.5 text-sm w-24" autoFocus />
<input value={creditDesc} onChange={e => setCreditDesc(e.target.value)}
placeholder="Комментарий" className="input py-1.5 text-sm flex-1 min-w-40" />
<button onClick={() => applyCredit(u.id)} disabled={saving || !creditAmount}
className="btn-primary py-1.5 px-3 text-sm">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Начислить'}
</button>
<button onClick={() => setShowCredit(false)} className="btn-ghost p-1.5"><X className="w-4 h-4" /></button>
</div>
)}
{/* Форма смены тарифа */}
{showPlan && (
<div className="mt-4 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20 flex items-center gap-2 flex-wrap">
<select value={newPlan} onChange={e => setNewPlan(e.target.value)}
className="input py-1.5 text-sm flex-1">
<option value="">Выберите тариф...</option>
{planOptions.map(p => <option key={p.code} value={p.code}>{p.name} {p.price_rub}/мес</option>)}
</select>
<button onClick={() => applyPlan(u.id)} disabled={saving || !newPlan}
className="btn-primary py-1.5 px-3 text-sm">Применить</button>
<button onClick={() => setShowPlan(false)} className="btn-ghost p-1.5"><X className="w-4 h-4" /></button>
</div>
)}
</div>
{/* Баланс */}
{detail.balance && (
<div className="grid grid-cols-3 gap-3">
<div className="card p-3 text-center border-accent/30">
<div className="text-2xl font-bold text-accent">{detail.balance.credits ?? 0}</div>
<div className="text-xs text-gray-400 mt-0.5">кредитов</div>
</div>
<div className="card p-3 text-center">
<div className={`text-sm font-bold px-2 py-0.5 rounded inline-block ${PLAN_BADGE[detail.balance.plan_code] || 'bg-gray-600 text-gray-200'}`}>
{detail.balance.plan_name || 'Free'}
</div>
<div className="text-xs text-gray-400 mt-1">тариф</div>
</div>
<div className="card p-3 text-center">
<div className="text-sm font-medium">{detail.balance.reset_at ? new Date(detail.balance.reset_at).toLocaleDateString('ru-RU') : '—'}</div>
<div className="text-xs text-gray-400 mt-0.5">сброс кредитов</div>
</div>
</div>
)}
{/* Каналы */}
{detail.channels.length > 0 && (
<div className="card p-4">
<h3 className="font-medium text-sm mb-3">Каналы ({detail.channels.length})</h3>
<div className="space-y-2">
{detail.channels.map(ch => (
<div key={ch.id} className="flex items-center gap-2 text-sm">
<span>{PLATFORM_ICONS[ch.platform] || '📢'}</span>
<span className="font-medium">{ch.name}</span>
{ch.tg_username && <span className="text-gray-500 text-xs">@{ch.tg_username}</span>}
<span className={`ml-auto text-xs px-1.5 py-0.5 rounded ${ch.is_active ? 'bg-green-500/20 text-green-400' : 'bg-gray-600 text-gray-400'}`}>
{ch.is_active ? 'активен' : 'выкл'}
</span>
</div>
))}
</div>
</div>
)}
{/* История транзакций */}
{detail.transactions.length > 0 && (
<div className="card overflow-hidden">
<div className="px-4 py-3 bg-surface2 text-sm font-medium">История транзакций</div>
{detail.transactions.map(tx => {
const meta = TX_LABELS[tx.type] || { icon: '💬', color: 'text-gray-400' };
return (
<div key={tx.id} className="flex items-center justify-between px-4 py-2.5 border-t border-border text-sm hover:bg-surface2/50">
<div className="flex items-center gap-2">
<span>{meta.icon}</span>
<span className="text-gray-300">{tx.description || tx.type}</span>
</div>
<div className="text-right">
<div className={`font-medium ${meta.color}`}>{tx.amount > 0 ? '+' : ''}{tx.amount} кр</div>
<div className="text-xs text-gray-500">{new Date(tx.created_at).toLocaleDateString('ru-RU')}</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
if (selected && detailLoading) {
return <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>;
}
// ── Список пользователей ──
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Пользователи</h2>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Поиск по email..."
className="input pl-8 py-1.5 text-sm w-52" />
</div>
<button onClick={loadUsers} className="btn-ghost p-2"><RefreshCw className="w-4 h-4" /></button>
</div>
</div>
{msg && <div className="text-sm text-green-400">{msg}</div>}
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && (
<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-center">Тариф</th>
<th className="px-4 py-2.5 text-right">Кредиты</th>
<th className="px-4 py-2.5 text-center">Статус</th>
<th className="px-4 py-2.5 text-right">Зарегистрирован</th>
<th className="px-4 py-2.5 text-center">Действия</th>
</tr>
</thead>
<tbody>
{filtered.map(u => (
<tr key={u.id} className="border-t border-border hover:bg-surface2/50">
<td className="px-4 py-2.5">
<div className="font-medium">{u.name || u.email}</div>
{u.name && <div className="text-xs text-gray-500">{u.email}</div>}
{u.is_admin && <span className="text-xs text-yellow-400">👑 admin</span>}
</td>
<td className="px-4 py-2.5 text-center">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${PLAN_BADGE[u.plan_code] || 'bg-gray-600 text-gray-200'}`}>
{u.plan_name || 'Free'}
</span>
</td>
<td className="px-4 py-2.5 text-right font-bold">{u.credits ?? 0}</td>
<td className="px-4 py-2.5 text-center">
{u.is_blocked
? <span className="text-xs text-red-400">🚫 Блок</span>
: <span className="text-xs text-green-400"> Активен</span>}
</td>
<td className="px-4 py-2.5 text-right text-xs text-gray-500">
{new Date(u.created_at).toLocaleDateString('ru-RU')}
</td>
<td className="px-4 py-2.5 text-center">
<button onClick={() => { setSelected(u.id); loadDetail(u.id); }}
className="btn-ghost px-2 py-1 text-xs flex items-center gap-1 mx-auto">
Открыть <ChevronRight className="w-3 h-3" />
</button>
</td>
</tr>
))}
{!filtered.length && (
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">Пользователи не найдены</td></tr>
)}
</tbody>
</table>
</div>
)}
</div>
);
}