|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
}
|