Polish: homework filters, users search/popup, admin comments
Homework (/curator/homework): - Search by student name/email - Filter by status (pending/reviewed) and course - Server-side pagination (20 per page) with URL params Users (/admin/users): - Search by name/email, filter by role - Hover popup on each row: enrolled courses + expiry dates + email - Pagination (20 per page) with URL params Comments (/admin/comments): - New admin page with all active comments - Search by author or text content - One-click delete (soft-delete) from the table - Pagination (30 per page) - Added "Комментарии" link to admin nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Enrollment = {
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
|
||||
type UserRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
emailVerified: boolean;
|
||||
createdAt: Date;
|
||||
enrollmentCount: number;
|
||||
enrollments: Enrollment[];
|
||||
};
|
||||
|
||||
const roleLabel: Record<string, string> = {
|
||||
admin: "Администратор",
|
||||
curator: "Куратор",
|
||||
student: "Ученик",
|
||||
};
|
||||
|
||||
const roleVariant: Record<string, "default" | "secondary" | "outline"> = {
|
||||
admin: "default",
|
||||
curator: "secondary",
|
||||
student: "outline",
|
||||
};
|
||||
|
||||
function UserPopup({ user }: { user: UserRow }) {
|
||||
const now = new Date();
|
||||
return (
|
||||
<div
|
||||
className="absolute z-50 right-0 top-full mt-1 w-72 p-4 space-y-3 text-sm"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "2px solid var(--foreground)",
|
||||
boxShadow: "4px 4px 0 0 var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{/* Contact */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Контакты</p>
|
||||
<p className="font-mono text-xs">{user.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Courses */}
|
||||
{user.enrollments.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
Курсы ({user.enrollments.length})
|
||||
</p>
|
||||
{user.enrollments.map((e) => {
|
||||
const expired = e.expiresAt && new Date(e.expiresAt) < now;
|
||||
return (
|
||||
<div key={e.courseId} className="flex items-start justify-between gap-2">
|
||||
<p className="text-xs flex-1 truncate">{e.courseTitle}</p>
|
||||
<span
|
||||
className="text-xs shrink-0"
|
||||
style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "var(--muted-foreground)" }}
|
||||
>
|
||||
{e.expiresAt
|
||||
? expired
|
||||
? "просрочен"
|
||||
: `до ${new Date(e.expiresAt).toLocaleDateString("ru-RU")}`
|
||||
: "бессрочно"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>Курсов нет</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/admin/users/${user.id}`}
|
||||
className="block text-xs underline"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Открыть профиль →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersTable({ users }: { users: UserRow[] }) {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-2xl overflow-visible">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50">
|
||||
{["Пользователь", "Роль", "Курсов", "Email подтверждён", "Зарегистрирован", ""].map((h) => (
|
||||
<th key={h} className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="border-b last:border-0"
|
||||
style={{ borderColor: "var(--border)" }}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
<Link
|
||||
href={`/admin/users/${user.id}`}
|
||||
className="font-medium hover:underline"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{user.name}
|
||||
</Link>
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={roleVariant[user.role] ?? "outline"}>
|
||||
{roleLabel[user.role] ?? user.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-600">{user.enrollmentCount}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`text-xs font-medium ${user.emailVerified ? "text-green-600" : "text-slate-400"}`}>
|
||||
{user.emailVerified ? "Да" : "Нет"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-400">
|
||||
{new Date(user.createdAt).toLocaleDateString("ru-RU")}
|
||||
</td>
|
||||
{/* Hover popup trigger */}
|
||||
<td className="px-3 py-3 relative">
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseEnter={() => setHoveredId(user.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-1"
|
||||
style={{ border: "1px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||
>
|
||||
···
|
||||
</button>
|
||||
{hoveredId === user.id && <UserPopup user={user} />}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user