141 lines
4.7 KiB
TypeScript
141 lines
4.7 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
||
import Link from "next/link";
|
||
import { UserPlus } from "lucide-react";
|
||
import { UsersTable } from "@/components/admin/users-table";
|
||
import { Suspense } from "react";
|
||
import { UsersSearch } from "@/components/admin/users-search";
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
interface Props {
|
||
searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string }>;
|
||
}
|
||
|
||
export default async function UsersPage({ searchParams }: Props) {
|
||
const { search = "", role = "", page = "1", balance = "" } = await searchParams;
|
||
const currentPage = Math.max(1, parseInt(page) || 1);
|
||
const skip = (currentPage - 1) * PAGE_SIZE;
|
||
|
||
// Collect userIds with non-zero balance if filter is active
|
||
let balanceUserIds: string[] | null = null;
|
||
if (balance === "nonzero") {
|
||
const groups = await prisma.balanceTransaction.groupBy({
|
||
by: ["userId"],
|
||
_sum: { amount: true },
|
||
having: { amount: { _sum: { not: { equals: 0 } } } },
|
||
});
|
||
balanceUserIds = groups.map((g) => g.userId);
|
||
}
|
||
|
||
const where = {
|
||
...(search
|
||
? {
|
||
OR: [
|
||
{ name: { contains: search, mode: "insensitive" as const } },
|
||
{ email: { contains: search, mode: "insensitive" as const } },
|
||
],
|
||
}
|
||
: {}),
|
||
...(role ? { role } : {}),
|
||
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
|
||
};
|
||
|
||
const [users, total] = await Promise.all([
|
||
prisma.user.findMany({
|
||
where,
|
||
orderBy: { createdAt: "desc" },
|
||
skip,
|
||
take: PAGE_SIZE,
|
||
include: {
|
||
_count: { select: { enrollments: true } },
|
||
enrollments: {
|
||
include: { course: { select: { title: true } } },
|
||
orderBy: { enrolledAt: "desc" },
|
||
},
|
||
},
|
||
}),
|
||
prisma.user.count({ where }),
|
||
]);
|
||
|
||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||
|
||
const tableUsers = users.map((u) => ({
|
||
id: u.id,
|
||
name: u.name,
|
||
email: u.email,
|
||
role: u.role,
|
||
emailVerified: u.emailVerified,
|
||
createdAt: u.createdAt,
|
||
enrollmentCount: u._count.enrollments,
|
||
enrollments: u.enrollments.map((e) => ({
|
||
courseId: e.courseId,
|
||
courseTitle: e.course.title,
|
||
expiresAt: e.expiresAt,
|
||
})),
|
||
}));
|
||
|
||
function pageUrl(p: number) {
|
||
const params = new URLSearchParams();
|
||
if (search) params.set("search", search);
|
||
if (role) params.set("role", role);
|
||
if (balance) params.set("balance", balance);
|
||
params.set("page", String(p));
|
||
return `/admin/users?${params.toString()}`;
|
||
}
|
||
|
||
return (
|
||
<div className="p-8">
|
||
<div className="mb-5 flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold text-slate-800">Пользователи</h1>
|
||
<p className="text-slate-500 text-sm mt-0.5">{total} пользователей</p>
|
||
</div>
|
||
<Link
|
||
href="/admin/users/new"
|
||
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
|
||
>
|
||
<UserPlus size={14} />
|
||
Добавить пользователя
|
||
</Link>
|
||
</div>
|
||
|
||
{/* Filters */}
|
||
<Suspense>
|
||
<UsersSearch initialSearch={search} initialRole={role} initialBalance={balance} />
|
||
</Suspense>
|
||
|
||
<UsersTable users={tableUsers} />
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center gap-1 mt-4">
|
||
{currentPage > 1 && (
|
||
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs">←</Link>
|
||
)}
|
||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
||
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
||
acc.push(p);
|
||
return acc;
|
||
}, [])
|
||
.map((p, i) =>
|
||
p === "…" ? (
|
||
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}>…</span>
|
||
) : (
|
||
<Link key={p} href={pageUrl(p as number)} className="px-3 py-1 text-xs"
|
||
style={{ border: "2px solid var(--border)", background: p === currentPage ? "var(--foreground)" : "transparent", color: p === currentPage ? "var(--background)" : "var(--foreground)" }}>
|
||
{p}
|
||
</Link>
|
||
)
|
||
)}
|
||
{currentPage < totalPages && (
|
||
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||
)}
|
||
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|