- Leaderboard: rank-based percentile tiers (champion/gold/silver/bronze),
deterministic tie-breaking (xp DESC, user_id ASC)
- Mentor chat: md: breakpoints, safe-area-inset-bottom padding
- Learning progress: server-side progressPct, curriculum expansion safety
- Physical orders: hard-disabled with 410 Gone
- Certificate approval: pending/issued flow, admin issue/revoke endpoints,
certificate_requires_approval setting (migration 0031)
- Draft/publish: is_published on lessons and chapters, admin publish UI
(migration 0030)
- Video panel: static thumbnails (no preload), client-side pagination
- Rich editor: TipTap + tiptap-markdown for lesson descriptions,
marked + DOMPurify for SafeMarkdown rendering
510 lines
23 KiB
TypeScript
510 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
AlertTriangle,
|
|
Award,
|
|
Flame,
|
|
Heart,
|
|
Loader2,
|
|
Lock,
|
|
Search,
|
|
Sparkles,
|
|
Trophy,
|
|
Unlock,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { http, toUserError } from "@/lib/api";
|
|
import type {
|
|
LearningOverview,
|
|
StudentLearningRow,
|
|
UserLearningDetail,
|
|
} from "@/lib/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { toast } from "@/components/providers";
|
|
import { cn, formatFaNumber } from "@/lib/utils";
|
|
import {
|
|
DEFAULT_PAGE_SIZE,
|
|
ListPagination,
|
|
buildPageQuery,
|
|
type Paginated,
|
|
} from "@/components/admin/list-pagination";
|
|
|
|
type SortKey = "xp" | "progress" | "activity" | "streak";
|
|
type FilterKey = "all" | "active" | "locked";
|
|
|
|
function pct(n: number) {
|
|
return `${formatFaNumber(Math.round(n))}٪`;
|
|
}
|
|
|
|
function fmtDate(v?: string) {
|
|
if (!v) return "—";
|
|
try {
|
|
return new Date(v).toLocaleString("fa-IR", { dateStyle: "medium", timeStyle: "short" });
|
|
} catch {
|
|
return v;
|
|
}
|
|
}
|
|
|
|
export function LearningPanel() {
|
|
const qc = useQueryClient();
|
|
const [q, setQ] = useState("");
|
|
const [filter, setFilter] = useState<FilterKey>("all");
|
|
const [sort, setSort] = useState<SortKey>("xp");
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [unlocking, setUnlocking] = useState(false);
|
|
const [issuingCert, setIssuingCert] = useState(false);
|
|
const [page, setPage] = useState(1);
|
|
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [q, filter, sort]);
|
|
|
|
const overviewQ = useQuery({
|
|
queryKey: ["admin", "learning", "overview"],
|
|
queryFn: () => http.get<{ overview: LearningOverview }>("/api/admin/learning/overview"),
|
|
retry: 1,
|
|
});
|
|
|
|
const studentsQ = useQuery({
|
|
queryKey: ["admin", "learning", "students", q, filter, sort, page],
|
|
queryFn: () => {
|
|
const params = new URLSearchParams(buildPageQuery(page));
|
|
if (q.trim()) params.set("q", q.trim());
|
|
if (filter !== "all") params.set("filter", filter);
|
|
params.set("sort", sort);
|
|
return http.get<Paginated<StudentLearningRow>>(
|
|
`/api/admin/learning/students?${params.toString()}`,
|
|
);
|
|
},
|
|
placeholderData: keepPreviousData,
|
|
retry: 1,
|
|
});
|
|
|
|
const detailQ = useQuery({
|
|
queryKey: ["admin", "learning", "user", selectedId],
|
|
queryFn: () => http.get<{ learning: UserLearningDetail }>(`/api/admin/users/${selectedId}/learning`),
|
|
enabled: selectedId != null,
|
|
retry: 1,
|
|
});
|
|
|
|
const unlockStudent = async (id: number) => {
|
|
setUnlocking(true);
|
|
try {
|
|
await http.post(`/api/admin/users/${id}/unlock`);
|
|
toast.success("قفل حساب باز شد و جانها بازگردانی شدند");
|
|
await Promise.all([
|
|
qc.invalidateQueries({ queryKey: ["admin", "learning"] }),
|
|
qc.invalidateQueries({ queryKey: ["admin", "learning", "user", id] }),
|
|
]);
|
|
} catch (e) {
|
|
toast.error(toUserError(e, "باز کردن قفل ممکن نشد"));
|
|
} finally {
|
|
setUnlocking(false);
|
|
}
|
|
};
|
|
|
|
const issueCertificate = async (id: number) => {
|
|
setIssuingCert(true);
|
|
try {
|
|
await http.post(`/api/admin/users/${id}/certificate/issue`, {});
|
|
toast.success("گواهینامه با موفقیت صادر شد");
|
|
await qc.invalidateQueries({ queryKey: ["admin", "learning", "user", id] });
|
|
} catch (e) {
|
|
toast.error(toUserError(e, "صدور گواهینامه ممکن نشد"));
|
|
} finally {
|
|
setIssuingCert(false);
|
|
}
|
|
};
|
|
|
|
const overview = overviewQ.data?.overview;
|
|
const students = studentsQ.data?.items ?? [];
|
|
const studentsTotal = studentsQ.data?.total ?? 0;
|
|
const studentsPageSize = studentsQ.data?.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
|
|
const cards = overview
|
|
? [
|
|
{ label: "هنرجویان فعال", value: overview.studentsActive, hint: `از ${formatFaNumber(overview.studentsTotal)} نفر`, icon: Users },
|
|
{ label: "قفلشده", value: overview.studentsLocked, hint: "نیاز به باز کردن قفل", icon: Lock },
|
|
{ label: "تکمیل درس", value: overview.completions, hint: `${formatFaNumber(overview.lessonsTotal)} درس فعال`, icon: Trophy },
|
|
{ label: "نرخ قبولی ۷روزه", value: Math.round(overview.passRate7d), hint: `${formatFaNumber(overview.passes7d)}/${formatFaNumber(overview.attempts7d)} تلاش`, icon: Sparkles, suffix: "٪" },
|
|
{ label: "میانگین تماشا", value: Math.round(overview.avgWatchPct), hint: "درسهای ناتمام", icon: Flame, suffix: "٪" },
|
|
{ label: "امتیاز ۷روزه", value: overview.xpAwarded7d, hint: `${formatFaNumber(overview.studentsStreaking)} با استریک`, icon: Sparkles },
|
|
]
|
|
: [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{(overviewQ.isError || studentsQ.isError) && (
|
|
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm">
|
|
<AlertTriangle className="h-4 w-4 shrink-0 text-destructive" />
|
|
<p className="min-w-0 flex-1 font-bold text-destructive">
|
|
{toUserError(overviewQ.error ?? studentsQ.error, "بارگذاری داشبورد یادگیری ممکن نشد")}
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
void overviewQ.refetch();
|
|
void studentsQ.refetch();
|
|
}}
|
|
>
|
|
تلاش دوباره
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{overviewQ.isLoading &&
|
|
[0, 1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-28 rounded-2xl" />)}
|
|
{cards.map((c) => (
|
|
<Card key={c.label}>
|
|
<CardContent className="pt-5">
|
|
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
<c.icon className="h-5 w-5" />
|
|
</div>
|
|
<p className="text-3xl font-black tabular-nums">
|
|
{formatFaNumber(c.value)}
|
|
{c.suffix ?? ""}
|
|
</p>
|
|
<p className="text-sm font-bold">{c.label}</p>
|
|
<p className="mt-1 text-[11px] text-muted-foreground">{c.hint}</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-col gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<CardTitle className="text-base">جدول هنرجویان</CardTitle>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{formatFaNumber(studentsTotal)} نفر · برای جزئیات روی ردیف بزنید
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="relative w-full sm:w-56">
|
|
<Search className="pointer-events-none absolute start-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="جستجوی نام یا ایمیل…"
|
|
className="h-10 ps-9"
|
|
/>
|
|
</div>
|
|
<select
|
|
className="h-10 rounded-lg border border-input bg-card px-3 text-sm font-bold outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value as FilterKey)}
|
|
>
|
|
<option value="all">همه</option>
|
|
<option value="active">فعال</option>
|
|
<option value="locked">قفلشده</option>
|
|
</select>
|
|
<select
|
|
className="h-10 rounded-lg border border-input bg-card px-3 text-sm font-bold outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
value={sort}
|
|
onChange={(e) => setSort(e.target.value as SortKey)}
|
|
>
|
|
<option value="xp">مرتبسازی: امتیاز</option>
|
|
<option value="progress">مرتبسازی: پیشرفت</option>
|
|
<option value="activity">مرتبسازی: آخرین فعالیت</option>
|
|
<option value="streak">مرتبسازی: استریک</option>
|
|
</select>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{studentsQ.isLoading && (
|
|
<div className="space-y-2">
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-16 rounded-xl" />
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[720px] text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border text-xs text-muted-foreground">
|
|
<th className="px-2 py-2 text-right font-bold">هنرجو</th>
|
|
<th className="px-2 py-2 text-right font-bold">پیشرفت</th>
|
|
<th className="px-2 py-2 text-right font-bold">امتیاز</th>
|
|
<th className="px-2 py-2 text-right font-bold">جان</th>
|
|
<th className="px-2 py-2 text-right font-bold">استریک</th>
|
|
<th className="px-2 py-2 text-right font-bold">۷روزه</th>
|
|
<th className="px-2 py-2 text-right font-bold">آخرین فعالیت</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{students.map((s) => (
|
|
<tr
|
|
key={s.id}
|
|
className="cursor-pointer border-b border-border/70 transition-colors hover:bg-muted/40"
|
|
onClick={() => setSelectedId(s.id)}
|
|
>
|
|
<td className="px-2 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<div className="min-w-0">
|
|
<p className="truncate font-extrabold">
|
|
{s.name}
|
|
{s.isLocked && (
|
|
<Badge variant="destructive" className="ms-2 px-1.5 text-[10px]">
|
|
قفل
|
|
</Badge>
|
|
)}
|
|
</p>
|
|
<p className="truncate text-[11px] text-muted-foreground" dir="ltr">
|
|
@{s.username}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-2 py-3">
|
|
<div className="w-28">
|
|
<div className="mb-1 flex justify-between text-[10px] font-bold">
|
|
<span>
|
|
{formatFaNumber(s.lessonsPassed)}/{formatFaNumber(s.lessonsTotal)}
|
|
</span>
|
|
<span>{pct(s.progressPct)}</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full rounded-full bg-primary"
|
|
style={{ width: `${Math.min(100, s.progressPct)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-2 py-3 font-bold tabular-nums">{formatFaNumber(s.xp)}</td>
|
|
<td className="px-2 py-3">
|
|
<span className="inline-flex items-center gap-1 font-bold">
|
|
<Heart className={cn("h-3.5 w-3.5", s.hearts <= 1 && "fill-current text-destructive")} />
|
|
{formatFaNumber(s.hearts)}
|
|
</span>
|
|
</td>
|
|
<td className="px-2 py-3 font-bold tabular-nums">{formatFaNumber(s.streakCurrent)}</td>
|
|
<td className="px-2 py-3 text-xs text-muted-foreground">
|
|
{formatFaNumber(s.passes7d)}/{formatFaNumber(s.attempts7d)}
|
|
{s.attempts7d > 0 && (
|
|
<span className="ms-1 font-bold text-foreground">({pct(s.passRate7d)})</span>
|
|
)}
|
|
</td>
|
|
<td className="px-2 py-3 text-xs text-muted-foreground">{fmtDate(s.lastActivityAt)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{students.length === 0 && !studentsQ.isLoading && (
|
|
<p className="py-10 text-center text-sm text-muted-foreground">هنرجویی یافت نشد.</p>
|
|
)}
|
|
</div>
|
|
<ListPagination
|
|
page={page}
|
|
pageSize={studentsPageSize}
|
|
total={studentsTotal}
|
|
onPageChange={setPage}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={selectedId != null} onOpenChange={(o) => !o && setSelectedId(null)}>
|
|
<DialogContent className="max-h-[92vh] max-w-3xl overflow-y-auto p-0 sm:rounded-2xl">
|
|
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start">
|
|
<DialogTitle className="text-lg font-black">
|
|
{detailQ.data?.learning.user.name ?? "جزئیات یادگیری"}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{detailQ.isError
|
|
? toUserError(detailQ.error, "بارگذاری جزئیات ممکن نشد")
|
|
: detailQ.data
|
|
? `@${detailQ.data.learning.user.username} · ${pct(detailQ.data.learning.user.progressPct)} مسیر`
|
|
: "در حال بارگذاری…"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-5 px-6 py-5">
|
|
{detailQ.isLoading && (
|
|
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin" /> بارگذاری جزئیات
|
|
</div>
|
|
)}
|
|
{detailQ.isError && (
|
|
<p className="text-sm font-bold text-destructive">{toUserError(detailQ.error)}</p>
|
|
)}
|
|
{detailQ.data && (
|
|
<>
|
|
{detailQ.data.learning.user.isLocked && (
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border-2 border-destructive/30 bg-destructive/5 px-4 py-3">
|
|
<div className="flex items-start gap-2">
|
|
<Lock className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
|
<div>
|
|
<p className="text-sm font-extrabold text-destructive">حساب قفل است</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
پس از باز کردن قفل، سه جان بازمیگردد و مسیر ادامه پیدا میکند.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="success"
|
|
disabled={unlocking}
|
|
onClick={() => unlockStudent(detailQ.data!.learning.user.id)}
|
|
className="gap-1.5"
|
|
>
|
|
{unlocking ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Unlock className="h-3.5 w-3.5" />}
|
|
باز کردن قفل
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
{[
|
|
{ l: "امتیاز", v: formatFaNumber(detailQ.data.learning.user.xp) },
|
|
{ l: "جان", v: formatFaNumber(detailQ.data.learning.user.hearts) },
|
|
{ l: "استریک", v: formatFaNumber(detailQ.data.learning.user.streakCurrent) },
|
|
{
|
|
l: "تکمیل",
|
|
v: `${formatFaNumber(detailQ.data.learning.user.lessonsPassed)}/${formatFaNumber(detailQ.data.learning.user.lessonsTotal)}`,
|
|
},
|
|
].map((x) => (
|
|
<div key={x.l} className="rounded-2xl border border-border px-3 py-2 text-center">
|
|
<p className="text-[10px] font-bold text-muted-foreground">{x.l}</p>
|
|
<p className="text-lg font-black tabular-nums">{x.v}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{detailQ.data.learning.user.progressPct >= 100 && (
|
|
<div className="flex items-center justify-between gap-3 rounded-xl border border-primary/30 bg-primary/5 px-3 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<Award className="h-4 w-4 shrink-0 text-primary" />
|
|
<p className="text-xs font-bold">دوره تکمیل شده — گواهینامه قابل صدور است</p>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
disabled={issuingCert}
|
|
onClick={() => issueCertificate(detailQ.data!.learning.user.id)}
|
|
className="gap-1.5"
|
|
>
|
|
{issuingCert ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Award className="h-3.5 w-3.5" />}
|
|
صدور گواهینامه
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<section className="space-y-2">
|
|
<h3 className="text-sm font-black">دروس</h3>
|
|
<div className="max-h-64 space-y-1.5 overflow-y-auto">
|
|
{detailQ.data.learning.lessons.map((l) => (
|
|
<div
|
|
key={l.lessonId}
|
|
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 rounded-xl border border-border px-3 py-2"
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-bold">
|
|
L{l.lessonId} · {l.title}
|
|
</p>
|
|
<p className="truncate text-[11px] text-muted-foreground">{l.chapterTitle}</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
<span className="text-[10px] font-bold tabular-nums text-muted-foreground">
|
|
{pct(l.watchedPct)}
|
|
</span>
|
|
{l.passedQuiz ? (
|
|
<Badge variant="success" className="text-[10px]">
|
|
قبول
|
|
</Badge>
|
|
) : l.quizUnlocked ? (
|
|
<Badge variant="secondary" className="text-[10px]">
|
|
آزمون باز
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="text-[10px]">
|
|
در حال تماشا
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="space-y-2">
|
|
<h3 className="text-sm font-black">تلاشهای آزمون</h3>
|
|
{detailQ.data.learning.attempts.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">هنوز تلاشی ثبت نشده.</p>
|
|
) : (
|
|
<div className="max-h-48 space-y-1.5 overflow-y-auto">
|
|
{detailQ.data.learning.attempts.map((a) => (
|
|
<div
|
|
key={a.id}
|
|
className="flex items-center justify-between gap-2 rounded-xl border border-border px-3 py-2 text-xs"
|
|
>
|
|
<span className="min-w-0 truncate font-bold">{a.lessonTitle}</span>
|
|
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
{formatFaNumber(a.correctCount)}/{formatFaNumber(a.total)} · {pct(a.scorePct)}
|
|
</span>
|
|
<Badge
|
|
variant={a.status === "passed" ? "success" : "destructive"}
|
|
className="shrink-0 text-[10px]"
|
|
>
|
|
{a.status === "passed" ? "قبول" : "رد"}
|
|
</Badge>
|
|
<span className="shrink-0 text-muted-foreground">{fmtDate(a.createdAt)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-black">امتیازها</h3>
|
|
<div className="max-h-40 space-y-1 overflow-y-auto text-xs">
|
|
{detailQ.data.learning.xpEvents.map((x) => (
|
|
<div key={x.id} className="flex justify-between gap-2 rounded-lg bg-muted/40 px-2 py-1.5">
|
|
<span className="font-bold text-gold">+{formatFaNumber(x.amount)}</span>
|
|
<span className="text-muted-foreground">{x.source}</span>
|
|
<span className="text-muted-foreground">{fmtDate(x.createdAt)}</span>
|
|
</div>
|
|
))}
|
|
{detailQ.data.learning.xpEvents.length === 0 && (
|
|
<p className="text-muted-foreground">رویداد امتیازی نیست.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-black">نشستهای تماشا</h3>
|
|
<div className="max-h-40 space-y-1 overflow-y-auto text-xs">
|
|
{detailQ.data.learning.sessions.map((s) => (
|
|
<div key={`${s.lessonId}-${s.startedAt}`} className="rounded-lg bg-muted/40 px-2 py-1.5">
|
|
<p className="truncate font-bold">{s.lessonTitle}</p>
|
|
<p className="text-muted-foreground">آخرین ضربان: {fmtDate(s.lastHeartbeatAt)}</p>
|
|
</div>
|
|
))}
|
|
{detailQ.data.learning.sessions.length === 0 && (
|
|
<p className="text-muted-foreground">نشست تماشایی نیست.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|