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:
@@ -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());
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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 { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import AdminBilling from './admin/AdminBilling';
|
import AdminBilling from './admin/AdminBilling';
|
||||||
|
import AdminUsers from './admin/AdminUsers';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
// Sidebar navigation
|
// Sidebar navigation
|
||||||
@@ -63,7 +64,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
|
|||||||
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
||||||
{section === 'spending' && <SpendingSection />}
|
{section === 'spending' && <SpendingSection />}
|
||||||
{section === 'plans' && <PlansSection />}
|
{section === 'plans' && <PlansSection />}
|
||||||
{section === 'billing' && <AdminBilling />}
|
{section === 'billing' && <AdminUsers />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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