Files
IdP/apps/frontend/app/admin/bots/page.tsx

300 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
AdminUser,
ManagedBot,
fetchAdminBotAccounts,
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 [botAccounts, setBotAccounts] = useState<AdminUser[]>([]);
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, accountsResponse] = await Promise.all([
fetchAdminBots({ search, page, limit: 20 }, token),
fetchAdminBotMetrics(token),
fetchAdminBotAccounts(search, token)
]);
setBots(botsResponse.bots ?? []);
setTotal(botsResponse.total ?? 0);
setMetrics(metricsResponse);
setBotAccounts(accountsResponse.users ?? []);
} 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}
<div className="mt-10">
<h3 className="mb-2 text-lg font-medium">Системные учётные записи ботов</h3>
<p className="mb-4 text-sm text-[#667085]">
Пользователи IdP, связанные с Telegram-ботами (BotFather и боты пользователей). Роль: «Бот».
</p>
<div className="overflow-hidden rounded-2xl border border-[#eceef4] bg-white">
<Table>
<TableHeader>
<TableRow>
<TableHead>Учётная запись</TableHead>
<TableHead>Telegram-бот</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Статус</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>
) : botAccounts.length ? (
botAccounts.map((account) => (
<TableRow key={account.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">{account.displayName}</p>
<p className="text-sm text-[#667085]">{account.username ? `@${account.username}` : account.id.slice(0, 8)}</p>
</div>
</div>
</TableCell>
<TableCell>
{account.linkedBotUsername ? `@${account.linkedBotUsername}` : '—'}
{account.isSystemBot ? <p className="text-xs text-[#3390ec]">Системный</p> : null}
</TableCell>
<TableCell>Бот</TableCell>
<TableCell>
<span className="inline-flex rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">
{account.status === 'ACTIVE' ? 'Активен' : account.status}
</span>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="py-10 text-center text-[#667085]">
Системные учётные записи не найдены
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</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>
);
}