300 lines
12 KiB
TypeScript
300 lines
12 KiB
TypeScript
'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, TableContainer, 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>
|
||
|
||
<TableContainer className="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>
|
||
</TableContainer>
|
||
|
||
{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>
|
||
<TableContainer className="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>
|
||
</TableContainer>
|
||
</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>
|
||
);
|
||
}
|