dd198349fb
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
197 lines
6.4 KiB
TypeScript
197 lines
6.4 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import Link from "next/link";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { authClient } from "@/lib/auth-client";
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
function ImpersonateButton({ userId }: { userId: string }) {
|
||
const router = useRouter();
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
async function handleImpersonate() {
|
||
setLoading(true);
|
||
try {
|
||
await authClient.admin.impersonateUser({ userId });
|
||
window.location.href = "/dashboard";
|
||
} catch {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={handleImpersonate}
|
||
disabled={loading}
|
||
className="text-xs px-2 py-1 transition-colors"
|
||
style={{
|
||
border: "1px solid var(--border)",
|
||
color: loading ? "var(--muted-foreground)" : "var(--foreground)",
|
||
background: "transparent",
|
||
}}
|
||
>
|
||
{loading ? "..." : "Войти как"}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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>
|
||
{/* Actions */}
|
||
<td className="px-3 py-3 relative">
|
||
<div className="flex items-center gap-2">
|
||
{user.role !== "admin" && <ImpersonateButton userId={user.id} />}
|
||
<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>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|