Files
67/apps/web/src/pages/cabinet/AdminPage.jsx
2026-06-22 20:13:14 +03:00

175 lines
8.2 KiB
JavaScript
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.
import { Activity, Eye, FileText, Users } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { adminApi, contentApi } from "../../shared/api/endpoints";
import { normalizeContentList, toEntity, toList } from "../../shared/api/normalize";
import { queryKeys } from "../../shared/api/queryKeys";
import { Badge } from "../../shared/ui/Badge";
import { Button } from "../../shared/ui/Button";
import { EmptyState, ErrorState, Skeleton } from "../../shared/ui/States";
const popularParams = { limit: 4, sort: "popular" };
function number(value) {
return Number(value ?? 0).toLocaleString("ru-RU");
}
function pickChart(summary) {
const values = summary.viewsByDay ?? summary.weeklyViews ?? summary.chart ?? [];
if (!Array.isArray(values)) return [];
return values.map((item) => (typeof item === "number" ? { value: item } : item));
}
export function AdminPage() {
const dashboardQuery = useQuery({
queryKey: queryKeys.adminDashboard,
queryFn: adminApi.dashboard,
});
const usersQuery = useQuery({
queryKey: queryKeys.adminUsers({ limit: 8 }),
queryFn: () => adminApi.users({ limit: 8 }),
});
const popularQuery = useQuery({
queryKey: queryKeys.content(popularParams),
queryFn: () => contentApi.list(popularParams),
});
const summary = toEntity(dashboardQuery.data) ?? {};
const users = toList(usersQuery.data);
const popular = normalizeContentList(popularQuery.data);
const chart = pickChart(summary);
const maxChart = Math.max(...chart.map((item) => Number(item.value ?? item.views ?? 0)), 1);
const stats = [
[Users, "Пользователи", summary.users ?? summary.userCount],
[FileText, "Материалы", summary.materials ?? summary.contentCount],
[Eye, "Просмотры", summary.views ?? summary.viewCount],
[Activity, "На модерации", summary.pending ?? summary.moderationCount],
];
return (
<div>
<p className="text-xs font-bold uppercase tracking-widest text-accent">Администрирование</p>
<h1 className="mt-2 font-serif text-4xl">Обзор системы</h1>
<div className="mt-8 grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{stats.map(([Icon, label, value]) => {
const ItemIcon = Icon;
return (
<div key={String(label)} className="rounded-lg border border-line bg-surface p-5">
<div className="flex items-center justify-between">
<ItemIcon className="size-5 text-accent" />
<Badge tone="accent">API</Badge>
</div>
{dashboardQuery.isLoading ? (
<Skeleton className="mt-6 h-10 w-24" />
) : (
<p className="mt-6 font-serif text-4xl">{number(value)}</p>
)}
<p className="mt-1 text-sm text-muted">{String(label)}</p>
</div>
);
})}
</div>
{dashboardQuery.isError && (
<div className="mt-6">
<ErrorState retry={() => dashboardQuery.refetch()} />
</div>
)}
<div className="mt-6 grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
<section className="rounded-lg border border-line bg-surface p-6">
<div className="flex items-center justify-between">
<h2 className="font-serif text-2xl">Просмотры за неделю</h2>
<select className="rounded-md border border-line bg-white px-3 py-2 text-sm text-ink dark:bg-paper dark:[color-scheme:dark]">
<option>7 дней</option>
<option>30 дней</option>
</select>
</div>
{dashboardQuery.isLoading && <Skeleton className="mt-8 h-64" />}
{dashboardQuery.isSuccess && chart.length === 0 && (
<div className="mt-8">
<EmptyState title="Нет данных графика" text="Передайте массив просмотров в dashboard, чтобы заполнить диаграмму." />
</div>
)}
{dashboardQuery.isSuccess && chart.length > 0 && (
<div className="mt-8 flex h-64 items-end gap-3 border-b border-l border-line px-3">
{chart.map((item, index) => {
const value = Number(item.value ?? item.views ?? 0);
return (
<div key={item.date ?? index} className="flex flex-1 flex-col items-center justify-end gap-2">
<div
className="w-full max-w-12 rounded-t-md bg-primary transition hover:bg-accent"
style={{ height: `${Math.max((value / maxChart) * 100, 4)}%` }}
/>
<span className="pb-2 text-xs text-muted">{item.label ?? item.day ?? index + 1}</span>
</div>
);
})}
</div>
)}
</section>
<section className="rounded-lg border border-line bg-surface p-6">
<h2 className="font-serif text-2xl">Популярное</h2>
<div className="mt-5">
{popularQuery.isLoading && <Skeleton className="h-56" />}
{popularQuery.isError && <ErrorState retry={() => popularQuery.refetch()} />}
{popularQuery.isSuccess && popular.length === 0 && (
<EmptyState title="Нет популярных материалов" text="Список заполнится после ответа `/api/content`." />
)}
{popularQuery.isSuccess && popular.length > 0 && (
<div className="divide-y divide-line">
{popular.map((material, index) => (
<div key={material.id} className="flex gap-3 py-4 first:pt-0">
<span className="font-serif text-2xl text-line">{String(index + 1).padStart(2, "0")}</span>
<div>
<p className="font-semibold leading-snug">{material.title}</p>
<p className="mt-1 text-xs text-muted">{number(material.views)} просмотров</p>
</div>
</div>
))}
</div>
)}
</div>
</section>
</div>
<section className="mt-6 overflow-hidden rounded-lg border border-line bg-surface">
<div className="flex items-center justify-between border-b border-line p-5">
<h2 className="font-serif text-2xl">Пользователи</h2>
<Button size="sm" variant="secondary">Все пользователи</Button>
</div>
{usersQuery.isLoading && <div className="p-5"><Skeleton className="h-44" /></div>}
{usersQuery.isError && <div className="p-5"><ErrorState retry={() => usersQuery.refetch()} /></div>}
{usersQuery.isSuccess && users.length === 0 && (
<div className="p-5">
<EmptyState title="Пользователи не найдены" text="Таблица заполнится данными из `/api/admin/users`." />
</div>
)}
{usersQuery.isSuccess && users.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full min-w-[42rem] text-left text-sm">
<thead className="bg-paper text-xs uppercase tracking-wider text-muted">
<tr>
<th className="px-5 py-3">Имя</th>
<th className="px-5 py-3">Почта</th>
<th className="px-5 py-3">Роль</th>
<th className="px-5 py-3">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{users.map((user) => (
<tr key={user.id ?? user.email}>
<td className="px-5 py-4 font-semibold">{user.name ?? user.fullName ?? "Без имени"}</td>
<td className="px-5 py-4 text-muted">{user.email}</td>
<td className="px-5 py-4"><Badge>{user.role ?? "user"}</Badge></td>
<td className="px-5 py-4"><Badge tone={user.active === false ? "danger" : "success"}>{user.active === false ? "Заблокирован" : "Активен"}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}