forked from admin/zeropost-tool
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user