175 lines
8.2 KiB
JavaScript
175 lines
8.2 KiB
JavaScript
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>
|
||
);
|
||
}
|