global update and global fix
This commit is contained in:
234
apps/frontend/app/admin/bots/page.tsx
Normal file
234
apps/frontend/app/admin/bots/page.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Ban, Bot, CheckCircle2, Loader2, MessageSquare, Search, Users } from 'lucide-react';
|
||||
import { AdminShell } from '@/components/id/admin-shell';
|
||||
import { useAuth } from '@/components/id/auth-provider';
|
||||
import { useToast } from '@/components/id/toast-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
AdminBotMetrics,
|
||||
ManagedBot,
|
||||
fetchAdminBotMetrics,
|
||||
fetchAdminBots,
|
||||
setAdminBotActive
|
||||
} from '@/lib/api';
|
||||
import { getAdminLandingPath } from '@/lib/admin-access';
|
||||
|
||||
export default function AdminBotsPage() {
|
||||
const router = useRouter();
|
||||
const { token, user: currentUser } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [bots, setBots] = useState<ManagedBot[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [metrics, setMetrics] = useState<AdminBotMetrics | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionBotId, setActionBotId] = useState<string | null>(null);
|
||||
|
||||
const loadBots = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [botsResponse, metricsResponse] = await Promise.all([
|
||||
fetchAdminBots({ search, page, limit: 20 }, token),
|
||||
fetchAdminBotMetrics(token)
|
||||
]);
|
||||
setBots(botsResponse.bots ?? []);
|
||||
setTotal(botsResponse.total ?? 0);
|
||||
setMetrics(metricsResponse);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить ботов');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, showToast, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser) return;
|
||||
if (!currentUser.canManageBots && !currentUser.isSuperAdmin) {
|
||||
router.replace(getAdminLandingPath(currentUser));
|
||||
}
|
||||
}, [currentUser, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?.canManageBots && !currentUser?.isSuperAdmin) return;
|
||||
void loadBots();
|
||||
}, [currentUser?.canManageBots, currentUser?.isSuperAdmin, loadBots]);
|
||||
|
||||
async function handleToggleActive(bot: ManagedBot) {
|
||||
if (!token) return;
|
||||
setActionBotId(bot.id);
|
||||
try {
|
||||
await setAdminBotActive(bot.id, !bot.isActive, token);
|
||||
showToast(bot.isActive ? 'Бот заблокирован' : 'Бот разблокирован');
|
||||
await loadBots();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : 'Не удалось изменить статус бота');
|
||||
} finally {
|
||||
setActionBotId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / 20));
|
||||
|
||||
return (
|
||||
<AdminShell active="/admin/bots">
|
||||
<h2 className="mb-4 text-2xl font-medium tracking-tight">Telegram-боты</h2>
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard label="Всего ботов" value={metrics?.totalBots ?? 0} icon={Bot} />
|
||||
<MetricCard label="Активных" value={metrics?.activeBots ?? 0} icon={CheckCircle2} accent="text-emerald-600" />
|
||||
<MetricCard label="Заблокированных" value={metrics?.blockedBots ?? 0} icon={Ban} accent="text-rose-600" />
|
||||
<MetricCard label="Сообщений" value={metrics?.totalMessages ?? 0} icon={MessageSquare} />
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[240px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||||
<Input
|
||||
className="rounded-xl pl-9"
|
||||
placeholder="Поиск по названию или @username"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[#667085]">Найдено: {total}</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-[#eceef4] bg-white">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Бот</TableHead>
|
||||
<TableHead>Владелец</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : bots.length ? (
|
||||
bots.map((bot) => (
|
||||
<TableRow key={bot.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#eef4ff] text-[#3390ec]">
|
||||
<Bot className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{bot.name}</p>
|
||||
<p className="text-sm text-[#667085]">@{bot.username}</p>
|
||||
{bot.isSystemBot ? <p className="text-xs text-[#3390ec]">Системный</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-[#667085]" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{bot.owner?.displayName ?? '—'}</p>
|
||||
{bot.owner?.username ? <p className="text-xs text-[#667085]">@{bot.owner.username}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={
|
||||
bot.isActive
|
||||
? 'inline-flex rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700'
|
||||
: 'inline-flex rounded-full bg-rose-50 px-2.5 py-1 text-xs font-medium text-rose-700'
|
||||
}
|
||||
>
|
||||
{bot.isActive ? 'Активен' : 'Заблокирован'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!bot.isSystemBot ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
disabled={actionBotId === bot.id}
|
||||
onClick={() => void handleToggleActive(bot)}
|
||||
>
|
||||
{actionBotId === bot.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : bot.isActive ? (
|
||||
'Заблокировать'
|
||||
) : (
|
||||
'Разблокировать'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-[#667085]">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
|
||||
Боты не найдены
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Button variant="outline" size="sm" className="rounded-xl" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>
|
||||
Назад
|
||||
</Button>
|
||||
<span className="text-sm text-[#667085]">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
Вперёд
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
accent
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: typeof Bot;
|
||||
accent?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#eceef4] bg-white p-4">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[#667085]">
|
||||
<Icon className={`h-4 w-4 ${accent ?? ''}`} />
|
||||
{label}
|
||||
</div>
|
||||
<p className="text-2xl font-semibold">{value.toLocaleString('ru-RU')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user