2026-10-07 19:40:26 +03:30
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
2026-10-09 19:05:59 +03:30
|
|
|
import Link from "next/link";
|
2026-10-07 19:40:26 +03:30
|
|
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
|
import {
|
|
|
|
|
BookOpen,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronUp,
|
|
|
|
|
Code2,
|
|
|
|
|
Copy,
|
|
|
|
|
Cpu,
|
|
|
|
|
Eye,
|
|
|
|
|
Flag,
|
|
|
|
|
GitBranch,
|
|
|
|
|
Globe,
|
|
|
|
|
GripVertical,
|
|
|
|
|
Heart,
|
|
|
|
|
Layers,
|
|
|
|
|
Loader2,
|
|
|
|
|
Lock,
|
|
|
|
|
MapPin,
|
|
|
|
|
Pencil,
|
|
|
|
|
Plus,
|
|
|
|
|
Rocket,
|
|
|
|
|
Save,
|
|
|
|
|
ShieldCheck,
|
|
|
|
|
Sparkles,
|
|
|
|
|
Target,
|
|
|
|
|
Terminal,
|
|
|
|
|
Timer,
|
|
|
|
|
TriangleAlert,
|
|
|
|
|
Trash2,
|
|
|
|
|
Unlock,
|
|
|
|
|
Video,
|
|
|
|
|
Zap,
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import { http, signedMediaUrl, toUserError } from "@/lib/api";
|
|
|
|
|
import type {
|
|
|
|
|
AdminEvent,
|
|
|
|
|
Chapter,
|
|
|
|
|
Lesson,
|
|
|
|
|
Role,
|
|
|
|
|
User,
|
|
|
|
|
} from "@/lib/types";
|
|
|
|
|
import { useAuth } from "@/lib/auth-store";
|
|
|
|
|
import {
|
|
|
|
|
clampPhoneInput,
|
|
|
|
|
normalizePhone,
|
|
|
|
|
PHONE_INPUT_MAX_LEN,
|
|
|
|
|
validateEmail,
|
|
|
|
|
validateName,
|
|
|
|
|
validatePassword,
|
|
|
|
|
validatePhone,
|
|
|
|
|
validateSecurityQA,
|
|
|
|
|
validateUsername,
|
|
|
|
|
} from "@/lib/validation";
|
|
|
|
|
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 { PasswordInput } from "@/components/ui/password-input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
|
import { UserAvatar, avatarPropsOf } from "@/components/ui/user-avatar";
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
import { cn, formatFaNumber, formatStreakLabel } from "@/lib/utils";
|
|
|
|
|
import { toast } from "@/components/providers";
|
|
|
|
|
import {
|
|
|
|
|
ADMIN_NAV,
|
|
|
|
|
defaultAdminTab,
|
|
|
|
|
findAdminNavItem,
|
|
|
|
|
isValidAdminTab,
|
|
|
|
|
type AdminTabId,
|
|
|
|
|
} from "@/components/admin/nav";
|
|
|
|
|
import { AdminPanelHeader } from "@/components/admin/panel-header";
|
|
|
|
|
import { useConfirm } from "@/components/admin/use-confirm";
|
|
|
|
|
import { StatsPanel } from "@/components/admin/stats-panel";
|
|
|
|
|
import { LearningPanel } from "@/components/admin/learning-panel";
|
|
|
|
|
import { QuestionsPanel } from "@/components/admin/questions-panel";
|
|
|
|
|
import { VideosPanel, VideoKeyPicker } from "@/components/admin/videos-panel";
|
|
|
|
|
import { AnnouncePanel } from "@/components/admin/announce-panel";
|
|
|
|
|
import { PhysicalOrdersPanel } from "@/components/admin/physical-orders-panel";
|
|
|
|
|
import {
|
|
|
|
|
DEFAULT_PAGE_SIZE,
|
|
|
|
|
ListPagination,
|
|
|
|
|
buildPageQuery,
|
|
|
|
|
type Paginated,
|
|
|
|
|
} from "@/components/admin/list-pagination";
|
|
|
|
|
import { AdminSelect, ListToolbar } from "@/components/admin/list-toolbar";
|
|
|
|
|
import { SiteSettingsPanel } from "@/components/admin/site-settings-panel";
|
|
|
|
|
import { RegistrationPanel } from "@/components/admin/registration-panel";
|
|
|
|
|
import { InvitesPanel } from "@/components/admin/invites-panel";
|
|
|
|
|
import { WhitelistPanel } from "@/components/admin/whitelist-panel";
|
|
|
|
|
import { BlacklistPanel } from "@/components/admin/blacklist-panel";
|
|
|
|
|
import { ChatExportPanel } from "@/components/admin/chat-export-panel";
|
|
|
|
|
import { ChallengesPanel } from "@/components/admin/challenges-panel";
|
|
|
|
|
import { JalaliDateTimePicker, isValidIsoRange } from "@/components/ui/jalali-datetime-picker";
|
|
|
|
|
|
|
|
|
|
const CHAPTER_ICON_OPTIONS: { key: string; icon: typeof Terminal; label: string }[] = [
|
|
|
|
|
{ key: "terminal", icon: Terminal, label: "ترمینال" },
|
|
|
|
|
{ key: "chip", icon: Cpu, label: "تراشه" },
|
|
|
|
|
{ key: "rocket", icon: Rocket, label: "موشک" },
|
|
|
|
|
{ key: "book", icon: BookOpen, label: "کتاب" },
|
|
|
|
|
{ key: "layers", icon: Layers, label: "لایهها" },
|
|
|
|
|
{ key: "flag", icon: Flag, label: "پرچم" },
|
|
|
|
|
{ key: "target", icon: Target, label: "هدف" },
|
|
|
|
|
{ key: "zap", icon: Zap, label: "صاعقه" },
|
|
|
|
|
{ key: "code", icon: Code2, label: "کد" },
|
|
|
|
|
{ key: "globe", icon: Globe, label: "دنیا" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function chapterIconOf(key: string) {
|
|
|
|
|
return CHAPTER_ICON_OPTIONS.find((o) => o.key === key)?.icon ?? Layers;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openSignedMedia(key: string) {
|
|
|
|
|
if (!key) return;
|
|
|
|
|
try {
|
|
|
|
|
const url = await signedMediaUrl(key);
|
|
|
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e, "باز کردن فایل ممکن نشد"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function copyToClipboard(text: string) {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
|
|
|
|
toast.success("در کلیپبورد کپی شد");
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error("کپی ممکن نشد");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDur(sec: number) {
|
|
|
|
|
if (!sec || sec < 60) return `${sec || 0} ث`;
|
|
|
|
|
const m = Math.floor(sec / 60);
|
|
|
|
|
const s = sec % 60;
|
|
|
|
|
return s ? `${m}د ${s}ث` : `${m} دقیقه`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MENTOR_HINT =
|
|
|
|
|
"وضعیت کلی یادگیری هنرجویان، دروس، سوالات، ویدیوها و رویدادهای آموزشی.";
|
|
|
|
|
|
|
|
|
|
export function AdminStudio() {
|
|
|
|
|
const user = useAuth((s) => s.user);
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const pathname = usePathname();
|
|
|
|
|
const searchParams = useSearchParams();
|
|
|
|
|
const isAdmin = user?.role === "admin";
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (user && user.role !== "admin" && user.role !== "mentor") {
|
|
|
|
|
router.replace("/cap");
|
|
|
|
|
}
|
|
|
|
|
}, [user, router]);
|
|
|
|
|
|
|
|
|
|
if (user && user.role !== "admin" && user.role !== "mentor") {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rawTab = searchParams.get("tab") ?? "";
|
|
|
|
|
const tab: AdminTabId = isValidAdminTab(rawTab, !!isAdmin)
|
|
|
|
|
? rawTab
|
|
|
|
|
: defaultAdminTab(!!isAdmin);
|
|
|
|
|
|
|
|
|
|
const setTab = useCallback(
|
|
|
|
|
(next: AdminTabId) => {
|
|
|
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
|
|
|
params.set("tab", next);
|
|
|
|
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
|
|
|
|
},
|
|
|
|
|
[pathname, router, searchParams]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isValidAdminTab(rawTab, !!isAdmin)) {
|
|
|
|
|
setTab(defaultAdminTab(!!isAdmin));
|
|
|
|
|
}
|
|
|
|
|
}, [rawTab, isAdmin, setTab]);
|
|
|
|
|
|
|
|
|
|
const navItem = findAdminNavItem(tab);
|
|
|
|
|
const visibleGroups = ADMIN_NAV.map((group) => ({
|
|
|
|
|
...group,
|
|
|
|
|
items: group.items.filter((i) => !i.adminOnly || isAdmin),
|
|
|
|
|
})).filter((g) => g.items.length > 0);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="section-kicker">{isAdmin ? "استودیو ادمین" : "استودیو منتور"}</p>
|
|
|
|
|
<h1 className="mt-1.5 text-2xl font-bold tracking-tight sm:text-3xl">
|
|
|
|
|
{isAdmin ? (
|
|
|
|
|
<>
|
|
|
|
|
پنل <span className="text-gradient-brand">مدیریت</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
پنل <span className="text-gradient-brand">منتور</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
|
|
|
|
{isAdmin
|
|
|
|
|
? "افراد، ثبتنام، محتوای دوره، ارتباط و تنظیمات سایت — گروهبندیشده تا چیزی گم نشود."
|
|
|
|
|
: MENTOR_HINT}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant="accent" className="gap-1 rounded-lg px-3 py-1.5">
|
|
|
|
|
<ShieldCheck className="h-4 w-4" /> {isAdmin ? "ادمین" : "منتور"}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Mobile: horizontal grouped chips */}
|
|
|
|
|
<nav className="lg:hidden" aria-label={isAdmin ? "بخشهای مدیریت" : "بخشهای منتور"}>
|
|
|
|
|
<div className="-mx-1 flex gap-3 overflow-x-auto px-1 pb-1">
|
|
|
|
|
{visibleGroups.map((group) => (
|
|
|
|
|
<div key={group.title} className="flex shrink-0 flex-col gap-1.5">
|
|
|
|
|
<p className="px-1 text-[10px] font-bold text-muted-foreground">{group.title}</p>
|
|
|
|
|
<div className="flex gap-1.5">
|
|
|
|
|
{group.items.map((item) => {
|
|
|
|
|
const Icon = item.icon;
|
|
|
|
|
const active = tab === item.id;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={item.id}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setTab(item.id)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-xl px-3 py-2 text-xs font-bold transition-colors",
|
|
|
|
|
active
|
|
|
|
|
? "bg-primary text-primary-foreground shadow-offset-sm"
|
|
|
|
|
: "border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Icon className="h-3.5 w-3.5 shrink-0" />
|
|
|
|
|
{item.label}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
|
|
|
|
|
<aside className="hidden w-52 shrink-0 lg:sticky lg:top-24 lg:block">
|
|
|
|
|
<nav
|
|
|
|
|
className="space-y-3 rounded-xl border border-border bg-card p-2.5"
|
|
|
|
|
aria-label={isAdmin ? "منوی مدیریت" : "منوی منتور"}
|
|
|
|
|
>
|
|
|
|
|
{visibleGroups.map((group) => (
|
|
|
|
|
<div key={group.title}>
|
|
|
|
|
<p className="mb-1 px-2 text-[11px] font-bold text-muted-foreground">
|
|
|
|
|
{group.title}
|
|
|
|
|
</p>
|
|
|
|
|
<ul className="space-y-0.5">
|
|
|
|
|
{group.items.map((item) => {
|
|
|
|
|
const Icon = item.icon;
|
|
|
|
|
const active = tab === item.id;
|
|
|
|
|
return (
|
|
|
|
|
<li key={item.id}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setTab(item.id)}
|
|
|
|
|
aria-current={active ? "page" : undefined}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm font-bold transition-colors",
|
|
|
|
|
active
|
|
|
|
|
? "bg-primary text-primary-foreground shadow-offset-sm"
|
|
|
|
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Icon className="h-4 w-4 shrink-0" />
|
|
|
|
|
{item.label}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
{navItem && (
|
|
|
|
|
<AdminPanelHeader
|
|
|
|
|
title={navItem.label}
|
|
|
|
|
description={navItem.description}
|
|
|
|
|
icon={navItem.icon}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{tab === "stats" && isAdmin && <StatsPanel />}
|
|
|
|
|
{tab === "learning" && <LearningPanel />}
|
|
|
|
|
{tab === "users" && isAdmin && <UsersPanel />}
|
|
|
|
|
{tab === "orders" && isAdmin && <PhysicalOrdersPanel />}
|
|
|
|
|
{tab === "registration" && isAdmin && <RegistrationPanel onNavigate={setTab} />}
|
|
|
|
|
{tab === "invites" && isAdmin && <InvitesPanel />}
|
|
|
|
|
{tab === "whitelist" && isAdmin && <WhitelistPanel />}
|
|
|
|
|
{tab === "blacklist" && isAdmin && <BlacklistPanel />}
|
|
|
|
|
{tab === "settings" && isAdmin && <SiteSettingsPanel onNavigate={setTab} />}
|
|
|
|
|
{tab === "curriculum" && <CurriculumPanel />}
|
|
|
|
|
{tab === "questions" && <QuestionsPanel />}
|
|
|
|
|
{tab === "videos" && <VideosPanel />}
|
|
|
|
|
{tab === "events" && <AdminEventsPanel />}
|
|
|
|
|
{tab === "challenges" && isAdmin && <ChallengesPanel />}
|
|
|
|
|
{tab === "announce" && isAdmin && <AnnouncePanel />}
|
|
|
|
|
{tab === "chat-export" && isAdmin && <ChatExportPanel />}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
function UsersPanel() {
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const currentUser = useAuth((s) => s.user);
|
|
|
|
|
const isStaff = currentUser?.role === "admin" || currentUser?.role === "mentor";
|
|
|
|
|
const [q, setQ] = useState("");
|
|
|
|
|
const [roleFilter, setRoleFilter] = useState("");
|
|
|
|
|
const [page, setPage] = useState(1);
|
|
|
|
|
const [busy, setBusy] = useState<number | null>(null);
|
|
|
|
|
const [dialogUser, setDialogUser] = useState<User | null>(null);
|
|
|
|
|
const { confirm, dialog } = useConfirm();
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setPage(1);
|
|
|
|
|
}, [q, roleFilter]);
|
|
|
|
|
|
|
|
|
|
const { data, isLoading } = useQuery({
|
|
|
|
|
queryKey: ["admin", "users", q, roleFilter, page],
|
|
|
|
|
queryFn: () => {
|
|
|
|
|
const params = new URLSearchParams(buildPageQuery(page));
|
|
|
|
|
if (q) params.set("q", q);
|
|
|
|
|
if (roleFilter) params.set("role", roleFilter);
|
|
|
|
|
return http.get<Paginated<User>>(`/api/admin/users?${params.toString()}`);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const users = data?.items ?? [];
|
|
|
|
|
const total = data?.total ?? 0;
|
|
|
|
|
const pageSize = data?.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
|
|
|
|
|
|
|
|
const invalidate = () => {
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "users"] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const act = async (id: number, kind: "lock" | "unlock") => {
|
|
|
|
|
setBusy(id);
|
|
|
|
|
try {
|
|
|
|
|
if (kind === "lock") await http.post(`/api/admin/users/${id}/lock`);
|
|
|
|
|
else await http.post(`/api/admin/users/${id}/unlock`);
|
|
|
|
|
invalidate();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const del = async (u: User) => {
|
|
|
|
|
if (
|
|
|
|
|
!(await confirm(
|
|
|
|
|
"حذف کاربر",
|
|
|
|
|
`حساب «${u.name}» (${u.email}) برای همیشه حذف شود؟ این عمل بازگشتناپذیر است.`
|
|
|
|
|
))
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setBusy(u.id);
|
|
|
|
|
try {
|
|
|
|
|
await http.del(`/api/admin/users/${u.id}`);
|
|
|
|
|
toast.success("کاربر حذف شد");
|
|
|
|
|
invalidate();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{dialog}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="space-y-3 pb-3">
|
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
<CardTitle className="text-base">فهرست حسابها</CardTitle>
|
|
|
|
|
{isStaff && (
|
|
|
|
|
<Button size="sm" onClick={() => setDialogUser({ id: 0, email: "", username: "", name: "", role: "student", xp: 0, hearts: 3, heartsUpdatedAt: "", streakCurrent: 0, streakLongest: 0, isLocked: false, isActive: true, createdAt: "" })}>
|
|
|
|
|
<Plus className="h-4 w-4" /> کاربر جدید
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<ListToolbar className="border-0 pb-0">
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="جستجو با نام، شناسه یا ایمیل…"
|
|
|
|
|
value={q}
|
|
|
|
|
onChange={(e) => setQ(e.target.value)}
|
|
|
|
|
className="max-w-xs"
|
|
|
|
|
/>
|
|
|
|
|
<AdminSelect
|
|
|
|
|
value={roleFilter}
|
|
|
|
|
onChange={(e) => setRoleFilter(e.target.value)}
|
|
|
|
|
>
|
|
|
|
|
<option value="">همه نقشها</option>
|
|
|
|
|
<option value="student">هنرجوها</option>
|
|
|
|
|
{isStaff && <option value="mentor">منتورها</option>}
|
|
|
|
|
{currentUser?.role === "admin" && <option value="admin">ادمینها</option>}
|
|
|
|
|
</AdminSelect>
|
|
|
|
|
{(q || roleFilter) && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setQ("");
|
|
|
|
|
setRoleFilter("");
|
|
|
|
|
setPage(1);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
پاک کردن
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</ListToolbar>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="pt-0">
|
|
|
|
|
<div className="divide-y divide-border rounded-lg border border-border">
|
|
|
|
|
{isLoading && (
|
|
|
|
|
<div className="space-y-0 divide-y divide-border">
|
|
|
|
|
{[0, 1, 2, 3, 4].map((i) => (
|
|
|
|
|
<div key={i} className="p-3">
|
|
|
|
|
<LoaderRow />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{(users).map((u) => {
|
|
|
|
|
const canMutate =
|
|
|
|
|
currentUser?.role === "admin" ||
|
|
|
|
|
(currentUser?.role === "mentor" && u.role === "student");
|
|
|
|
|
return (
|
|
|
|
|
<div key={u.id} className="flex items-center gap-3 px-3 py-2.5">
|
2026-10-09 19:05:59 +03:30
|
|
|
<Link
|
|
|
|
|
href={`/profile/${u.id}`}
|
|
|
|
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-md outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
>
|
2026-10-07 19:40:26 +03:30
|
|
|
<UserAvatar name={u.name} className="h-9 w-9" {...avatarPropsOf(u)} />
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<p className="flex items-center gap-2 truncate text-sm font-medium">
|
|
|
|
|
{u.name}
|
|
|
|
|
{u.id === currentUser?.id && <Badge variant="secondary" className="px-1.5 text-[10px]">شما</Badge>}
|
|
|
|
|
{u.isLocked && <Badge variant="destructive" className="px-1.5 text-[10px]">قفل شده</Badge>}
|
|
|
|
|
{u.role === "admin" && <Badge className="bg-primary/10 px-1.5 text-[10px] text-primary">ادمین</Badge>}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="truncate text-xs text-muted-foreground" dir="ltr">
|
|
|
|
|
@{u.username} · {u.email}
|
|
|
|
|
{u.phone ? ` · ${u.phone}` : ""}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="hidden gap-3 text-xs text-muted-foreground sm:flex">
|
|
|
|
|
<span>{formatFaNumber(u.xp)} امتیاز</span>
|
|
|
|
|
<span>{formatStreakLabel(u.streakCurrent)}</span>
|
|
|
|
|
<span className={cn("inline-flex items-center gap-1", u.role === "student" && u.hearts <= 1 && "font-bold text-destructive")}>
|
|
|
|
|
<Heart className="h-3.5 w-3.5 fill-current" />{" "}
|
|
|
|
|
{u.role === "admin" || u.role === "mentor" ? "∞" : formatFaNumber(u.hearts)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-10-09 19:05:59 +03:30
|
|
|
</Link>
|
2026-10-07 19:40:26 +03:30
|
|
|
<div className="flex shrink-0 gap-1.5">
|
|
|
|
|
{canMutate && u.id !== currentUser?.id && (
|
|
|
|
|
u.isLocked ? (
|
|
|
|
|
<Button size="sm" variant="success" disabled={busy === u.id} onClick={() => act(u.id, "unlock")}>
|
|
|
|
|
{busy === u.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Unlock className="h-3.5 w-3.5" />} فعالسازی
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button size="sm" variant="destructive" disabled={busy === u.id} onClick={() => act(u.id, "lock")}>
|
|
|
|
|
{busy === u.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Lock className="h-3.5 w-3.5" />} قفل
|
|
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
{canMutate && (
|
|
|
|
|
<Button size="sm" variant="outline" className="h-8 px-2" onClick={() => setDialogUser(u)} title="ویرایش">
|
|
|
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{canMutate && u.id !== currentUser?.id && (
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-8 px-2 text-destructive" disabled={busy === u.id} onClick={() => del(u)} title="حذف">
|
|
|
|
|
{busy === u.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{users.length === 0 && !isLoading && (
|
|
|
|
|
<p className="py-8 text-center text-sm text-muted-foreground">کاربری یافت نشد.</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3">
|
|
|
|
|
<ListPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
<UserDialog user={dialogUser} onClose={() => setDialogUser(null)} onSaved={invalidate} />
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-09 19:05:59 +03:30
|
|
|
export function UserDialog({
|
2026-10-07 19:40:26 +03:30
|
|
|
user,
|
|
|
|
|
onClose,
|
|
|
|
|
onSaved,
|
|
|
|
|
}: {
|
|
|
|
|
user: User | null;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onSaved: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const currentUser = useAuth((s) => s.user);
|
|
|
|
|
const isNew = !!user && user.id === 0;
|
|
|
|
|
const [form, setForm] = useState<User>(user ?? ({} as User));
|
|
|
|
|
const [password, setPassword] = useState("");
|
|
|
|
|
const [securityAnswer, setSecurityAnswer] = useState("");
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (user) {
|
|
|
|
|
setForm(user);
|
|
|
|
|
setPassword("");
|
|
|
|
|
setSecurityAnswer("");
|
|
|
|
|
}
|
|
|
|
|
}, [user]);
|
|
|
|
|
|
|
|
|
|
const isSelf = !isNew && !!user && user.id === currentUser?.id;
|
|
|
|
|
|
|
|
|
|
const save = async () => {
|
|
|
|
|
if (!form) return;
|
|
|
|
|
const nameErr = validateName(form.name ?? "");
|
|
|
|
|
if (nameErr) {
|
|
|
|
|
toast.error(nameErr);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const userErr = validateUsername(form.username ?? "");
|
|
|
|
|
if (userErr) {
|
|
|
|
|
toast.error(userErr);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const emailErr = validateEmail(form.email ?? "");
|
|
|
|
|
if (emailErr) {
|
|
|
|
|
toast.error(emailErr);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const phoneErr = validatePhone(form.phone ?? "", false);
|
|
|
|
|
if (phoneErr) {
|
|
|
|
|
toast.error(phoneErr);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (isSelf && form.role !== currentUser?.role) {
|
|
|
|
|
toast.error("نمیتوانید نقش حساب خودتان را تغییر دهید");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (isNew) {
|
|
|
|
|
const pwErr = validatePassword(password);
|
|
|
|
|
if (pwErr) {
|
|
|
|
|
toast.error(pwErr);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const secErr = validateSecurityQA(form.securityQuestion ?? "", securityAnswer);
|
|
|
|
|
if (secErr) {
|
|
|
|
|
toast.error(secErr);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} else if (securityAnswer.trim()) {
|
|
|
|
|
const q = (form.securityQuestion ?? "").trim();
|
|
|
|
|
if (q) {
|
|
|
|
|
const secErr = validateSecurityQA(q, securityAnswer);
|
|
|
|
|
if (secErr) {
|
|
|
|
|
toast.error(secErr);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
} else if ([...securityAnswer.trim()].length < 2) {
|
|
|
|
|
toast.error("جواب امنیتی خیلی کوتاه است");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
if (isNew) {
|
|
|
|
|
await http.post("/api/admin/users", {
|
|
|
|
|
name: (form.name ?? "").trim(),
|
|
|
|
|
email: (form.email ?? "").trim().toLowerCase(),
|
|
|
|
|
username: (form.username ?? "").trim().toLowerCase(),
|
|
|
|
|
password,
|
|
|
|
|
role: form.role,
|
|
|
|
|
phone: normalizePhone(form.phone ?? ""),
|
|
|
|
|
securityQuestion: (form.securityQuestion ?? "").trim(),
|
|
|
|
|
securityAnswer: securityAnswer.trim(),
|
|
|
|
|
});
|
|
|
|
|
toast.success("کاربر ساخته شد");
|
|
|
|
|
} else {
|
|
|
|
|
await http.put(`/api/admin/users/${form.id}`, {
|
|
|
|
|
name: (form.name ?? "").trim(),
|
|
|
|
|
email: (form.email ?? "").trim().toLowerCase(),
|
|
|
|
|
username: (form.username ?? "").trim().toLowerCase(),
|
|
|
|
|
phone: normalizePhone(form.phone ?? ""),
|
|
|
|
|
...(form.securityQuestion?.trim()
|
|
|
|
|
? { securityQuestion: form.securityQuestion.trim() }
|
|
|
|
|
: {}),
|
|
|
|
|
...(securityAnswer.trim() ? { securityAnswer: securityAnswer.trim() } : {}),
|
|
|
|
|
...(isSelf ? {} : { role: form.role, hearts: form.hearts, xp: form.xp }),
|
|
|
|
|
});
|
|
|
|
|
toast.success("کاربر بهروزرسانی شد");
|
|
|
|
|
}
|
|
|
|
|
onSaved();
|
|
|
|
|
onClose();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={!!user} onOpenChange={(o) => !o && onClose()}>
|
|
|
|
|
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{isNew ? "کاربر جدید" : `ویرایش «${form.name}»`}</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
{isNew
|
|
|
|
|
? "حساب جدید با تلفن و سوال امنیتی. رمز حداقل ۸ کاراکتر."
|
2026-10-09 19:05:59 +03:30
|
|
|
: "اطلاعات کاربر را بهروزرسانی کنید."}
|
2026-10-07 19:40:26 +03:30
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="grid gap-3">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>نام</Label>
|
|
|
|
|
<Input value={form.name ?? ""} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>شناسه</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.username ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, username: e.target.value.toLowerCase() })}
|
|
|
|
|
dir="ltr"
|
|
|
|
|
className="text-left"
|
|
|
|
|
placeholder="مثلاً ahp"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>ایمیل</Label>
|
|
|
|
|
<Input type="email" value={form.email ?? ""} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>تلفن</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.phone ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, phone: clampPhoneInput(e.target.value) })}
|
|
|
|
|
dir="ltr"
|
|
|
|
|
className="text-left"
|
|
|
|
|
placeholder="09xxxxxxxxx"
|
|
|
|
|
inputMode="tel"
|
|
|
|
|
maxLength={PHONE_INPUT_MAX_LEN}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>نقش</Label>
|
|
|
|
|
<select
|
|
|
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
|
|
value={form.role ?? "student"}
|
|
|
|
|
disabled={isSelf}
|
|
|
|
|
onChange={(e) => setForm({ ...form, role: e.target.value as Role })}
|
|
|
|
|
>
|
|
|
|
|
<option value="student">هنرجو</option>
|
|
|
|
|
{currentUser?.role === "admin" && <option value="mentor">منتور</option>}
|
|
|
|
|
{currentUser?.role === "admin" && <option value="admin">ادمین</option>}
|
|
|
|
|
</select>
|
|
|
|
|
{isSelf && (
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">نقش حساب خودتان قابل تغییر نیست.</p>
|
|
|
|
|
)}
|
|
|
|
|
{!isSelf && currentUser?.role === "mentor" && (
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">منتور فقط میتواند نقش هنرجو را تنظیم کند.</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{!isNew && !isSelf && (
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>قلب</Label>
|
|
|
|
|
<Input
|
|
|
|
|
type="number"
|
|
|
|
|
min={0}
|
|
|
|
|
max={3}
|
|
|
|
|
value={form.hearts ?? 0}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setForm({ ...form, hearts: Math.max(0, Math.min(3, Number(e.target.value) || 0)) })
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>امتیاز (XP)</Label>
|
|
|
|
|
<Input type="number" min={0} value={form.xp ?? 0} onChange={(e) => setForm({ ...form, xp: Number(e.target.value) })} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>سوال امنیتی{!isNew && form.securityQuestion ? " (فعلی نمایش داده میشود)" : ""}</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={form.securityQuestion ?? ""}
|
|
|
|
|
onChange={(e) => setForm({ ...form, securityQuestion: e.target.value })}
|
|
|
|
|
placeholder="مثلاً نام اولین مدرسه؟"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>{isNew ? "جواب امنیتی" : "جواب امنیتی جدید (خالی = بدون تغییر)"}</Label>
|
|
|
|
|
<PasswordInput
|
|
|
|
|
value={securityAnswer}
|
|
|
|
|
onChange={(e) => setSecurityAnswer(e.target.value)}
|
|
|
|
|
placeholder={isNew ? "الزامی" : "فقط در صورت تغییر"}
|
|
|
|
|
dir="rtl"
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{isNew && (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>رمز</Label>
|
|
|
|
|
<PasswordInput
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
minLength={8}
|
|
|
|
|
placeholder="حداقل ۸ کاراکتر"
|
|
|
|
|
required
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={onClose}>انصراف</Button>
|
|
|
|
|
<Button onClick={save} disabled={saving || !(form.name ?? "").trim() || (isNew && password.length < 8)}>
|
|
|
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} {isNew ? "ساخت" : "ذخیره"}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CurriculumPanel() {
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const { confirm, dialog } = useConfirm();
|
|
|
|
|
const { data, isLoading } = useQuery({
|
|
|
|
|
queryKey: ["admin", "lessons"],
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
const [c, l] = await Promise.all([
|
|
|
|
|
http.get<{ chapters: Chapter[] }>("/api/admin/chapters"),
|
|
|
|
|
http.get<{ lessons: Lesson[] }>("/api/admin/lessons"),
|
|
|
|
|
]);
|
|
|
|
|
return { chapters: c.chapters, lessons: l.lessons };
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
const chapters = [...(data?.chapters ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
|
|
|
|
const lessons = (data?.lessons ?? []).slice().sort((a, b) => a.sortOrder - b.sortOrder);
|
|
|
|
|
|
|
|
|
|
const [lessonModal, setLessonModal] = useState<Lesson | null>(null);
|
|
|
|
|
const [chapterModal, setChapterModal] = useState<Chapter | null>(null);
|
|
|
|
|
const [dragIdx, setDragIdx] = useState<{ chapter: number; from: number; over?: number } | null>(null);
|
|
|
|
|
|
|
|
|
|
const persistChapterOrder = async (list: Chapter[]) => {
|
|
|
|
|
for (let i = 0; i < list.length; i++) {
|
|
|
|
|
const ch = list[i];
|
|
|
|
|
if (ch.sortOrder !== i + 1) await http.put(`/api/admin/chapters/${ch.id}`, { ...ch, sortOrder: i + 1 });
|
|
|
|
|
}
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const persistLessonOrder = async (list: Lesson[]) => {
|
|
|
|
|
for (let i = 0; i < list.length; i++) {
|
|
|
|
|
const l = list[i];
|
|
|
|
|
if (l.sortOrder !== i + 1) await http.put(`/api/admin/lessons/${l.id}`, { ...l, sortOrder: i + 1 });
|
|
|
|
|
}
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const moveChapter = async (idx: number, delta: number) => {
|
|
|
|
|
const to = idx + delta;
|
|
|
|
|
if (to < 0 || to >= chapters.length) return;
|
|
|
|
|
const next = reorderArray(chapters, idx, to);
|
|
|
|
|
try {
|
|
|
|
|
await persistChapterOrder(next);
|
|
|
|
|
toast.success("ترتیب فصلها بهروزرسانی شد");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const moveLesson = async (chapterId: number, idx: number, delta: number) => {
|
|
|
|
|
const ls = lessons.filter((l) => l.chapterId === chapterId);
|
|
|
|
|
const to = idx + delta;
|
|
|
|
|
if (to < 0 || to >= ls.length) return;
|
|
|
|
|
const next = reorderArray(ls, idx, to);
|
|
|
|
|
try {
|
|
|
|
|
await persistLessonOrder(next);
|
|
|
|
|
toast.success("ترتیب درسها بهروزرسانی شد");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveChapter = async (ch: Chapter) => {
|
|
|
|
|
try {
|
|
|
|
|
if (ch.id) await http.put(`/api/admin/chapters/${ch.id}`, ch);
|
|
|
|
|
else await http.post("/api/admin/chapters", { ...ch, sortOrder: chapters.length + 1, id: 0 });
|
|
|
|
|
setChapterModal(null);
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
|
|
|
|
toast.success(ch.id ? "فصل بهروزرسانی شد" : "فصل ساخته شد");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveLesson = async (lv: Lesson) => {
|
|
|
|
|
try {
|
|
|
|
|
if (lv.id) await http.put(`/api/admin/lessons/${lv.id}`, lv);
|
|
|
|
|
else await http.post("/api/admin/lessons", lv);
|
|
|
|
|
setLessonModal(null);
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
|
|
|
|
toast.success(lv.id ? "درس بهروزرسانی شد" : "درس ساخته شد");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const newLesson = (chapterId: number) =>
|
|
|
|
|
setLessonModal({
|
|
|
|
|
id: 0, chapterId, title: "", description: "", videoKey: "",
|
|
|
|
|
durationSeconds: 45, completionThresholdPct: 85, requiresLessonId: undefined,
|
|
|
|
|
x: lessons.filter((l) => l.chapterId === chapterId).length, y: 0, sortOrder: 1, xpReward: 50, isActive: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const duplicateLesson = async (l: Lesson) => {
|
|
|
|
|
try {
|
|
|
|
|
await http.post("/api/admin/lessons", { ...l, id: 0, title: `${l.title} (کپی)`, sortOrder: 1 });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
|
|
|
|
toast.success("کپی درس ساخته شد");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const delLesson = async (l: Lesson) => {
|
|
|
|
|
if (!(await confirm("حذف درس", `درس «${l.title}» (L${l.id}) با سوالاتش حذف شود؟`))) return;
|
|
|
|
|
try {
|
|
|
|
|
await http.del(`/api/admin/lessons/${l.id}`);
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
|
|
|
|
toast.success("درس حذف شد");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const delChapter = async (ch: Chapter) => {
|
|
|
|
|
if (
|
|
|
|
|
!(await confirm(
|
|
|
|
|
"حذف فصل",
|
|
|
|
|
`فصل «${ch.title}» با همه درسهایش حذف شود؟ این عمل بازگشتناپذیر است.`
|
|
|
|
|
))
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await http.del(`/api/admin/chapters/${ch.id}`);
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
|
|
|
|
toast.success("فصل حذف شد");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{dialog}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex-row items-center justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle className="text-base">فصلها و درخت مهارت</CardTitle>
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
|
|
|
{chapters.length} فصل · {lessons.length} درس · ترتیب با کشیدن یا فلش · موقعیت x,y
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button className="gap-1.5 rounded-2xl" onClick={() => setChapterModal({ id: 0, title: "", description: "", icon: "terminal", sortOrder: chapters.length + 1 })}>
|
|
|
|
|
<Plus className="h-4 w-4" /> فصل جدید
|
|
|
|
|
</Button>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
{isLoading && <Loader />}
|
|
|
|
|
{chapters.map((ch, ci) => {
|
|
|
|
|
const ls = lessons.filter((l) => l.chapterId === ch.id);
|
|
|
|
|
const ChIcon = chapterIconOf(ch.icon);
|
|
|
|
|
return (
|
|
|
|
|
<div key={ch.id} className={cn("rounded-xl border border-border bg-muted/20", dragIdx !== null && dragIdx?.chapter === ch.id && "border-primary/50")}>
|
|
|
|
|
<div className="flex items-center gap-2 p-3 pb-2">
|
|
|
|
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-primary/10 text-primary">
|
|
|
|
|
<ChIcon className="h-4 w-4" />
|
|
|
|
|
</span>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<h3 className="truncate font-bold">{ch.title}</h3>
|
|
|
|
|
{ch.description && <p className="truncate text-xs text-muted-foreground">{ch.description}</p>}
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant="outline" className="shrink-0">{ls.length} درس</Badge>
|
|
|
|
|
<Badge variant="secondary" className="hidden shrink-0 sm:inline-flex">
|
|
|
|
|
{ls.filter((l) => l.videoKey).length}/{ls.length} ویدیو
|
|
|
|
|
</Badge>
|
|
|
|
|
<GroupNav
|
|
|
|
|
onUp={ci > 0 ? () => moveChapter(ci, -1) : undefined}
|
|
|
|
|
onDown={ci < chapters.length - 1 ? () => moveChapter(ci, 1) : undefined}
|
|
|
|
|
/>
|
|
|
|
|
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => setChapterModal(ch)}>
|
|
|
|
|
<Pencil className="h-3 w-3" /> فصل
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={() => newLesson(ch.id)}>
|
|
|
|
|
<Plus className="h-3.5 w-3.5" /> درس
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive" onClick={() => delChapter(ch)} title="حذف فصل">
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{ls.length === 0 ? (
|
|
|
|
|
<p className="px-3 pb-3 text-xs text-muted-foreground">هنوز درسی نیست. «+ درس» را بزنید.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="px-3 pb-3">
|
|
|
|
|
<div className="grid gap-1.5 sm:grid-cols-2">
|
|
|
|
|
{ls.map((l, li) => {
|
|
|
|
|
const cols = ls.filter((o) => o.x === l.x && o.y === l.y && o.id !== l.id);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={l.id}
|
|
|
|
|
draggable
|
|
|
|
|
onDragStart={() => setDragIdx({ chapter: ch.id, from: li })}
|
|
|
|
|
onDragOver={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
e.dataTransfer.dropEffect = "move";
|
|
|
|
|
setDragIdx((d) => (d ? { ...d, over: li, chapter: ch.id } : d));
|
|
|
|
|
}}
|
|
|
|
|
onDrop={() => {
|
|
|
|
|
if (dragIdx && dragIdx.chapter === ch.id && dragIdx.from !== li) {
|
|
|
|
|
const idx = dragIdx.from;
|
|
|
|
|
const next = reorderArray(ls, idx, li);
|
|
|
|
|
persistLessonOrder(next).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
setDragIdx(null);
|
|
|
|
|
}}
|
|
|
|
|
onDragEnd={() => setDragIdx(null)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"group flex cursor-grab items-center gap-2 rounded-lg border border-border bg-card px-3 py-2.5 transition-colors active:cursor-grabbing",
|
|
|
|
|
dragIdx?.from === li && dragIdx.chapter === ch.id && "opacity-40",
|
|
|
|
|
!l.videoKey && "border-dashed"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
|
|
|
|
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">L{l.id}</span>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<p className="truncate text-sm font-medium">{l.title}</p>
|
|
|
|
|
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
|
|
|
<Timer className="h-3 w-3" /> {formatDur(l.durationSeconds)}
|
|
|
|
|
<Sparkles className="h-3 w-3 text-gold" /> +{formatFaNumber(l.xpReward)}
|
|
|
|
|
<MapPin className="h-3 w-3" /> {l.x},{l.y}
|
|
|
|
|
{l.videoKey ? (
|
|
|
|
|
<span className="inline-flex items-center gap-0.5 text-success"><Video className="h-3 w-3" /> ویدیو</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-warning">بدون ویدیو</span>
|
|
|
|
|
)}
|
|
|
|
|
{cols.length > 0 && <Badge variant="destructive" className="px-1 text-[9px]">تداخل</Badge>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant={l.isActive ? "success" : "secondary"} className="px-1.5 text-[10px]">
|
|
|
|
|
{l.isActive ? "فعال" : "پیشنویس"}
|
|
|
|
|
</Badge>
|
|
|
|
|
<div className="flex shrink-0 gap-0.5">
|
|
|
|
|
<GroupNav onUp={li > 0 ? () => moveLesson(ch.id, li, -1) : undefined} onDown={li < ls.length - 1 ? () => moveLesson(ch.id, li, 1) : undefined} compact />
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-7 px-1.5" title="تکثیر" onClick={() => duplicateLesson(l)}>
|
|
|
|
|
<Copy className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-7 px-1.5" onClick={() => setLessonModal(l)} title="ویرایش">
|
|
|
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-7 w-7 px-0 text-destructive opacity-60 hover:opacity-100" onClick={() => delLesson(l)} title="حذف درس">
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{chapters.length === 0 && !isLoading && (
|
|
|
|
|
<div className="rounded-xl border border-dashed border-border p-8 text-center">
|
|
|
|
|
<p className="font-bold">هنوز فصلی نیست</p>
|
|
|
|
|
<p className="mt-1 text-sm text-muted-foreground">با «فصل جدید» شروع کنید؛ درسهای هر فصل روی درخت مهارت هنرجو نمایش داده میشود.</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<ChapterDialog chapter={chapterModal} onClose={() => setChapterModal(null)} onSave={saveChapter} />
|
|
|
|
|
<LessonDialog
|
|
|
|
|
lesson={lessonModal}
|
|
|
|
|
chapters={chapters}
|
|
|
|
|
lessons={lessons}
|
|
|
|
|
onClose={() => setLessonModal(null)}
|
|
|
|
|
onSave={saveLesson}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function reorderArray<T>(arr: T[], from: number, to: number): T[] {
|
|
|
|
|
if (from === to || from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr;
|
|
|
|
|
const next = [...arr];
|
|
|
|
|
const [moved] = next.splice(from, 1);
|
|
|
|
|
next.splice(to, 0, moved);
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function GroupNav({
|
|
|
|
|
onUp,
|
|
|
|
|
onDown,
|
|
|
|
|
compact,
|
|
|
|
|
}: {
|
|
|
|
|
onUp?: () => void;
|
|
|
|
|
onDown?: () => void;
|
|
|
|
|
compact?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn("flex shrink-0 gap-0.5", compact ? "flex-col" : "flex-row")}>
|
|
|
|
|
{onUp && (
|
|
|
|
|
<Button size="sm" variant="ghost" className={cn("px-1 text-muted-foreground", compact ? "h-5" : "h-6")} onClick={onUp} title="بالا/جلو">
|
|
|
|
|
<ChevronUp className={compact ? "h-3 w-3" : "h-3.5 w-3.5"} />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
{onDown && (
|
|
|
|
|
<Button size="sm" variant="ghost" className={cn("px-1 text-muted-foreground", compact ? "h-5" : "h-6")} onClick={onDown} title="پایین/عقب">
|
|
|
|
|
<ChevronDown className={compact ? "h-3 w-3" : "h-3.5 w-3.5"} />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ChapterDialog({
|
|
|
|
|
chapter,
|
|
|
|
|
onClose,
|
|
|
|
|
onSave,
|
|
|
|
|
}: {
|
|
|
|
|
chapter: Chapter | null;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onSave: (c: Chapter) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const isNew = !!chapter && chapter.id === 0;
|
|
|
|
|
const [draft, setDraft] = useState<Chapter>(chapter ?? ({} as Chapter));
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (chapter) setDraft(chapter);
|
|
|
|
|
}, [chapter]);
|
|
|
|
|
const ChIcon = chapterIconOf(draft.icon);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={!!chapter} onOpenChange={(o) => !o && onClose()}>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{isNew ? "فصل جدید" : "ویرایش فصل"}</DialogTitle>
|
|
|
|
|
<DialogDescription>فصلها گروههای دروس درخت مهارت هستند و نمادشان روی نقشهٔ مسیر دیده میشود.</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="grid gap-4">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>عنوان</Label>
|
|
|
|
|
<Input value={draft.title ?? ""} onChange={(e) => setDraft({ ...draft, title: e.target.value })} autoFocus />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>توضیحات (کوتاه)</Label>
|
|
|
|
|
<Input value={draft.description ?? ""} onChange={(e) => setDraft({ ...draft, description: e.target.value })} placeholder="مثلاً مبانی ترمینال و گیت" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>نشان</Label>
|
|
|
|
|
<div className="grid grid-cols-5 gap-2">
|
|
|
|
|
{CHAPTER_ICON_OPTIONS.map((o) => {
|
|
|
|
|
const active = (draft.icon ?? "") === o.key;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={o.key}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setDraft({ ...draft, icon: o.key })}
|
|
|
|
|
title={o.label}
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex flex-col items-center gap-1 rounded-lg border px-1 py-2 transition-colors",
|
|
|
|
|
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:border-primary/40"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<o.icon className="h-4 w-4" />
|
|
|
|
|
<span className="text-[9px] leading-none">{o.label}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={onClose}>انصراف</Button>
|
|
|
|
|
<Button disabled={!(draft.title ?? "").trim()} onClick={() => draft && onSave(draft)}>
|
|
|
|
|
<Save className="h-4 w-4" /> {isNew ? "ساخت فصل" : "ذخیره فصل"}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
|
|
|
<ChIcon className="h-3.5 w-3.5" /> در نقشهٔ مسیر با این نشان نمایش داده میشود.
|
|
|
|
|
</p>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function LessonDialog({
|
|
|
|
|
lesson,
|
|
|
|
|
chapters,
|
|
|
|
|
lessons,
|
|
|
|
|
onClose,
|
|
|
|
|
onSave,
|
|
|
|
|
}: {
|
|
|
|
|
lesson: Lesson | null;
|
|
|
|
|
chapters: Chapter[];
|
|
|
|
|
lessons: Lesson[];
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onSave: (l: Lesson) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [draft, setDraft] = useState<Lesson>(() => lesson ?? { id: 0, chapterId: 1, title: "", description: "", videoKey: "", durationSeconds: 90, completionThresholdPct: 85, requiresLessonId: undefined, x: 5, y: 3, sortOrder: 1, xpReward: 50, isActive: true });
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (lesson) setDraft(lesson);
|
|
|
|
|
}, [lesson]);
|
|
|
|
|
const set = (patch: Partial<Lesson>) => setDraft((d) => ({ ...d, ...patch }));
|
|
|
|
|
const isNew = draft.id === 0;
|
|
|
|
|
|
|
|
|
|
const others = lessons.filter((l) => l.id !== draft.id);
|
|
|
|
|
const titleOk = draft.title.trim().length > 0;
|
|
|
|
|
const durOk = draft.durationSeconds > 0;
|
|
|
|
|
const thrOk = draft.completionThresholdPct >= 1 && draft.completionThresholdPct <= 100;
|
|
|
|
|
const valid = titleOk && durOk && thrOk;
|
|
|
|
|
|
|
|
|
|
const cycle = useMemo(() => {
|
|
|
|
|
if (!draft.requiresLessonId) return false;
|
|
|
|
|
let node: number | null = draft.requiresLessonId;
|
|
|
|
|
const seen = new Set<number>();
|
|
|
|
|
for (let i = 0; i < lessons.length + 1 && node; i++) {
|
|
|
|
|
if (node === draft.id) return true;
|
|
|
|
|
if (seen.has(node)) break;
|
|
|
|
|
seen.add(node);
|
|
|
|
|
node = lessons.find((l) => l.id === node)?.requiresLessonId ?? null;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}, [draft.requiresLessonId, draft.id, lessons]);
|
|
|
|
|
|
|
|
|
|
const collision = useMemo(
|
|
|
|
|
() => lessons.some((l) => l.id !== draft.id && l.chapterId === draft.chapterId && l.x === draft.x && l.y === draft.y),
|
|
|
|
|
[draft.chapterId, draft.id, draft.x, draft.y, lessons]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const prereq = lessons.find((l) => l.id === draft.requiresLessonId);
|
|
|
|
|
const requiredSeconds = Math.round((draft.durationSeconds * draft.completionThresholdPct) / 100);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={!!lesson} onOpenChange={(o) => !o && onClose()}>
|
|
|
|
|
<DialogContent className="flex max-h-[92vh] w-[calc(100%-1.5rem)] max-w-3xl flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl">
|
|
|
|
|
<DialogHeader className="shrink-0 border-b border-border px-6 pb-4 pt-5 text-start">
|
|
|
|
|
<DialogTitle className="flex flex-wrap items-center gap-2">
|
|
|
|
|
{isNew ? "درس جدید" : `ویرایش درس L${draft.id}`}
|
|
|
|
|
{!isNew && (
|
|
|
|
|
<Switch checked={draft.isActive} onCheckedChange={(v) => set({ isActive: v })} aria-label="وضعیت درس" />
|
|
|
|
|
)}
|
|
|
|
|
<span className={cn("text-xs font-bold", draft.isActive ? "text-success" : "text-muted-foreground")}>
|
|
|
|
|
{draft.isActive ? "فعال" : "پیشنویس"}
|
|
|
|
|
</span>
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription>برای فعال شدن آزمون، هنرجو باید {thrOk ? formatDur(requiredSeconds) : "—"} تماشای تاییدشده داشته باشد.</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
|
|
|
<div className="grid gap-0 md:grid-cols-[minmax(0,1fr)_240px]">
|
|
|
|
|
<div className="min-w-0 space-y-6 px-6 py-5">
|
|
|
|
|
{/* محتوا */}
|
|
|
|
|
<section className="space-y-3">
|
|
|
|
|
<SectionTitle icon={<BookOpen className="h-3.5 w-3.5" />} text="محتوا" />
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>عنوان درس</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={draft.title}
|
|
|
|
|
onChange={(e) => set({ title: e.target.value })}
|
|
|
|
|
placeholder="مثلاً HTTP در عمل"
|
|
|
|
|
className={cn(!titleOk && "border-destructive")}
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-right text-[11px] text-muted-foreground">{draft.title.length}/120 کاراکتر</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>توضیحات</Label>
|
|
|
|
|
<textarea
|
|
|
|
|
value={draft.description}
|
|
|
|
|
onChange={(e) => set({ description: e.target.value })}
|
|
|
|
|
rows={3}
|
|
|
|
|
placeholder="چرا این درس مهم است و چه چیزی میآموزد؟"
|
|
|
|
|
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<VideoKeyPicker value={draft.videoKey} onChange={(videoKey) => set({ videoKey })} />
|
|
|
|
|
{draft.videoKey ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void openSignedMedia(draft.videoKey)}
|
|
|
|
|
className="inline-flex items-center gap-1.5 text-xs text-primary hover:underline"
|
|
|
|
|
>
|
|
|
|
|
<Video className="h-3.5 w-3.5" /> پیشنمایش ویدیو
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="flex items-center gap-1.5 text-xs text-warning">
|
|
|
|
|
<Video className="h-3.5 w-3.5" /> بدون ویدیو — اول از تب ویدیوها آپلود کنید
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* ساختار */}
|
|
|
|
|
<section className="space-y-3">
|
|
|
|
|
<SectionTitle icon={<GitBranch className="h-3.5 w-3.5" />} text="ساختار و گرههای درخت" />
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>فصل</Label>
|
|
|
|
|
<select
|
|
|
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
|
|
|
value={draft.chapterId}
|
|
|
|
|
onChange={(e) => set({ chapterId: Number(e.target.value) })}
|
|
|
|
|
>
|
|
|
|
|
{chapters.map((c) => (
|
|
|
|
|
<option key={c.id} value={c.id}>{c.title}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>پیشنیاز (آزادکننده)</Label>
|
|
|
|
|
<select
|
|
|
|
|
className={cn(
|
|
|
|
|
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
|
|
|
|
|
cycle && "border-destructive"
|
|
|
|
|
)}
|
|
|
|
|
value={draft.requiresLessonId ? String(draft.requiresLessonId) : ""}
|
|
|
|
|
onChange={(e) => set({ requiresLessonId: e.target.value ? Number(e.target.value) : undefined })}
|
|
|
|
|
>
|
|
|
|
|
<option value="">بدون پیشنیاز (ریشه)</option>
|
|
|
|
|
{others.map((l) => (
|
|
|
|
|
<option key={l.id} value={l.id}>L{l.id} · {l.title}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{cycle && (
|
|
|
|
|
<p className="flex items-center gap-1.5 text-xs font-bold text-destructive">
|
|
|
|
|
<TriangleAlert className="h-3.5 w-3.5" /> این پیشنیاز زنجیرهٔ چرخهای میسازد و هرگز باز نمیشود.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{prereq && !cycle && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
تنها پس از قبولی در «{prereq.title}» (L{prereq.id})، این درس برای هنرجو باز میشود.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
|
|
|
<NumberField label="x (ستون)" value={draft.x} onChange={(v) => set({ x: v })} />
|
|
|
|
|
<NumberField label="y (ردیف)" value={draft.y} onChange={(v) => set({ y: v })} />
|
|
|
|
|
<NumberField label="ترتیب" value={draft.sortOrder} onChange={(v) => set({ sortOrder: v })} />
|
|
|
|
|
</div>
|
|
|
|
|
{collision && (
|
|
|
|
|
<p className="flex items-center gap-1.5 text-xs font-bold text-warning">
|
|
|
|
|
<MapPin className="h-3.5 w-3.5" /> جای {draft.x},{draft.y} همراستا با درس دیگری است — روی درخت روی هم میافتند.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{/* امتیاز و آزمون */}
|
|
|
|
|
<section className="space-y-3">
|
|
|
|
|
<SectionTitle icon={<Sparkles className="h-3.5 w-3.5" />} text="امتیاز و آزمون" />
|
|
|
|
|
<div className="grid grid-cols-3 gap-3">
|
|
|
|
|
<NumberField label="مدت (ثانیه)" value={draft.durationSeconds} onChange={(v) => set({ durationSeconds: v })} />
|
|
|
|
|
<NumberField label="آستانه ٪" value={draft.completionThresholdPct} onChange={(v) => set({ completionThresholdPct: Math.min(100, Math.max(1, v)) })} />
|
|
|
|
|
<NumberField label="پاداش XP" value={draft.xpReward} onChange={(v) => set({ xpReward: v })} />
|
|
|
|
|
</div>
|
|
|
|
|
{thrOk && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
آزمون با {formatDur(requiredSeconds)} تماشای تأییدشده از میان {formatDur(draft.durationSeconds)} باز میشود؛ قبولیِ تکتک پرسشها، شاخههای بعدی را آزاد میکند. روی هر پاسخ اشتباه یک جان کسر میشود.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* پیشنمایش زنده */}
|
|
|
|
|
<aside className="border-t border-border bg-muted/30 px-6 py-5 md:border-s md:border-t-0">
|
|
|
|
|
<p className="mb-3 flex items-center gap-1.5 text-xs font-bold text-muted-foreground">
|
|
|
|
|
<Eye className="h-3.5 w-3.5" /> پیشنمایش
|
|
|
|
|
</p>
|
|
|
|
|
<div className="rounded-lg border border-border bg-card p-4">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{draft.isActive ? <Badge variant="success">فعال</Badge> : <Badge variant="secondary">پیشنویس</Badge>}
|
|
|
|
|
{!isNew && <span className="text-[11px] font-bold text-muted-foreground">L{draft.id}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-2 text-sm font-black leading-snug">{draft.title || "عنوان درس"}</p>
|
|
|
|
|
<p className="mt-1 line-clamp-3 text-xs leading-relaxed text-muted-foreground">
|
|
|
|
|
{draft.description || "توضیحاتی که کنجکاوی هنرجو را برمیانگیزد."}
|
|
|
|
|
</p>
|
|
|
|
|
<div className="mt-3 flex flex-wrap gap-1.5 text-[11px] font-bold">
|
|
|
|
|
<span className="rounded-full bg-muted px-2 py-0.5">▶ {formatDur(draft.durationSeconds)}</span>
|
|
|
|
|
<span className="rounded-full bg-gold/10 px-2 py-0.5 text-gold">+{formatFaNumber(draft.xpReward)} امتیاز</span>
|
|
|
|
|
<span className="rounded-full bg-muted px-2 py-0.5 text-muted-foreground">آزمون {thrOk ? `${draft.completionThresholdPct}٪` : "؟"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{prereq && !cycle && (
|
|
|
|
|
<p className="mt-2.5 border-t border-border pt-2 text-[11px] text-muted-foreground">
|
|
|
|
|
🔓 پس از «{prereq.title}»
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-[11px] font-bold">
|
|
|
|
|
<div className="rounded-lg bg-primary/10 px-1 py-2 text-primary">ux= {draft.x}</div>
|
|
|
|
|
<div className="rounded-lg bg-accent/10 px-1 py-2 text-accent">uy= {draft.y}</div>
|
|
|
|
|
<div className="rounded-lg bg-success/10 px-1 py-2 text-success">ترتیب {draft.sortOrder}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter className="shrink-0 flex-row items-center justify-between gap-2 border-t border-border px-6 py-4 sm:space-x-0">
|
|
|
|
|
<Button variant="outline" className="h-10" onClick={onClose}>انصراف</Button>
|
|
|
|
|
<Button className="h-10 gap-1.5" onClick={() => valid && onSave(draft)} disabled={!valid}>
|
|
|
|
|
<Save className="h-4 w-4" /> {isNew ? "ساخت درس" : "ذخیره درس"}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>{label}</Label>
|
|
|
|
|
<Input type="number" value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SectionTitle({ icon, text }: { icon: React.ReactNode; text: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<p className="flex items-center gap-1.5 text-xs font-black uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
<span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 text-primary">{icon}</span>
|
|
|
|
|
{text}
|
|
|
|
|
</p>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
function AdminEventsPanel() {
|
|
|
|
|
const qc = useQueryClient();
|
|
|
|
|
const { confirm, dialog } = useConfirm();
|
|
|
|
|
const [modal, setModal] = useState<AdminEvent | null>(null);
|
|
|
|
|
const { data, isLoading } = useQuery({
|
|
|
|
|
queryKey: ["admin", "events"],
|
|
|
|
|
queryFn: () => http.get<{ events: AdminEvent[] }>("/api/admin/events"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const save = async (ev: AdminEvent) => {
|
|
|
|
|
if (!isValidIsoRange(ev.startsAt, ev.endsAt)) {
|
|
|
|
|
toast.error("زمان پایان باید پس از زمان شروع باشد");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const { createdAt: _c, rsvped: _r, ...body } = ev;
|
|
|
|
|
if (ev.id) await http.put(`/api/admin/events/${ev.id}`, body);
|
|
|
|
|
else await http.post("/api/admin/events", body);
|
|
|
|
|
setModal(null);
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "events"] });
|
|
|
|
|
toast.success(ev.id ? "رویداد بهروزرسانی شد" : "رویداد ساخته شد");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const del = async (ev: AdminEvent) => {
|
|
|
|
|
if (!(await confirm("حذف رویداد", `رویداد «${ev.title}» حذف شود؟`))) return;
|
|
|
|
|
try {
|
|
|
|
|
await http.del(`/api/admin/events/${ev.id}`);
|
|
|
|
|
qc.invalidateQueries({ queryKey: ["admin", "events"] });
|
|
|
|
|
toast.success("رویداد حذف شد");
|
|
|
|
|
} catch (e) {
|
|
|
|
|
toast.error(toUserError(e));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fmt = (iso: string) =>
|
|
|
|
|
new Intl.DateTimeFormat("fa-IR", { weekday: "short", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(iso));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{dialog}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex-row items-center justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<CardTitle className="text-base">فهرست رویدادها</CardTitle>
|
|
|
|
|
<p className="mt-1 text-xs text-muted-foreground">در مرکز هنرجو نمایش داده میشوند.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button className="gap-1.5" onClick={() => setModal({ id: 0, title: "", description: "", eventType: "workshop", externalUrl: "", startsAt: "", endsAt: "", rsvped: false, isActive: true, createdAt: "" })}>
|
|
|
|
|
<Plus className="h-4 w-4" /> رویداد جدید
|
|
|
|
|
</Button>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{isLoading && <Loader />}
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
|
|
{(data?.events ?? []).map((e) => (
|
|
|
|
|
<div key={e.id} className="rounded-md border border-border p-4">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Badge variant="accent">{e.eventType === "workshop" ? "کارگاه" : e.eventType === "meet" ? "جلسه" : "آنلاین"}</Badge>
|
|
|
|
|
<Badge variant={e.isActive ? "success" : "secondary"}>{e.isActive ? "فعال" : "پنهان"}</Badge>
|
|
|
|
|
<div className="ms-auto flex gap-1">
|
|
|
|
|
{e.externalUrl && (
|
|
|
|
|
<Button size="icon" variant="ghost" className="h-7 w-7" title="کپی لینک پیوستن" onClick={() => copyToClipboard(e.externalUrl)}>
|
|
|
|
|
<Copy className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button size="sm" variant="outline" className="h-7 px-2" onClick={() => setModal({ ...e })}>ویرایش</Button>
|
|
|
|
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-destructive" onClick={() => del(e)} title="حذف">
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-2 font-medium">{e.title}</p>
|
|
|
|
|
<p className="line-clamp-1 text-xs text-muted-foreground">{e.description || "—"}</p>
|
|
|
|
|
<p className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground">
|
|
|
|
|
<Timer className="h-3.5 w-3.5" /> {fmt(e.startsAt)} تا {new Date(e.endsAt).toLocaleTimeString("fa-IR")}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{(data?.events ?? []).length === 0 && !isLoading && (
|
|
|
|
|
<p className="py-8 text-center text-sm text-muted-foreground">هنوز رویدادی نیست.</p>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
<EventDialog event={modal} onClose={() => setModal(null)} onSave={save} />
|
|
|
|
|
</Card>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function EventDialog({ event, onClose, onSave }: { event: AdminEvent | null; onClose: () => void; onSave: (e: AdminEvent) => void }) {
|
|
|
|
|
const [draft, setDraft] = useState<AdminEvent | null>(event);
|
|
|
|
|
useEffect(() => setDraft(event), [event]);
|
|
|
|
|
const set = (patch: Partial<AdminEvent>) => setDraft((d) => (d ? { ...d, ...patch } : d));
|
|
|
|
|
const valid =
|
|
|
|
|
!!draft?.title.trim() &&
|
|
|
|
|
!!draft?.startsAt &&
|
|
|
|
|
!!draft?.endsAt &&
|
|
|
|
|
isValidIsoRange(draft.startsAt, draft.endsAt);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={!!event} onOpenChange={(o) => !o && onClose()}>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>{draft?.id ? "ویرایش رویداد" : "رویداد جدید"}</DialogTitle>
|
|
|
|
|
<DialogDescription>زمانها با تقویم جلالی انتخاب میشوند و در مرکز هنرجو نمایش داده میشوند.</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
{draft && (
|
|
|
|
|
<div className="grid gap-3">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>عنوان</Label>
|
|
|
|
|
<Input value={draft.title} onChange={(e) => set({ title: e.target.value })} placeholder="مثلاً کارگاه Git و گیتهاب" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>توضیحات</Label>
|
|
|
|
|
<textarea
|
|
|
|
|
value={draft.description}
|
|
|
|
|
onChange={(e) => set({ description: e.target.value })}
|
|
|
|
|
rows={3}
|
|
|
|
|
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
|
|
|
placeholder="شرح کوتاه رویداد (اختیاری)…"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>نوع</Label>
|
|
|
|
|
<select className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" value={draft.eventType} onChange={(e) => set({ eventType: e.target.value as AdminEvent["eventType"] })}>
|
|
|
|
|
<option value="workshop">کارگاه</option>
|
|
|
|
|
<option value="meet">دیدار</option>
|
|
|
|
|
<option value="zoom">آنلاین</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>لینک پیوستن</Label>
|
|
|
|
|
<Input dir="ltr" value={draft.externalUrl} onChange={(e) => set({ externalUrl: e.target.value })} placeholder="https://…" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>شروع (جلالی)</Label>
|
|
|
|
|
<JalaliDateTimePicker value={draft.startsAt} onChange={(iso) => set({ startsAt: iso })} aria-label="زمان شروع جلالی" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label>پایان (جلالی)</Label>
|
|
|
|
|
<JalaliDateTimePicker value={draft.endsAt} onChange={(iso) => set({ endsAt: iso })} aria-label="زمان پایان جلالی" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{draft.startsAt && draft.endsAt && !isValidIsoRange(draft.startsAt, draft.endsAt) && (
|
|
|
|
|
<p className="text-xs text-destructive">زمان پایان باید پس از زمان شروع باشد.</p>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2.5">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium">فعال / قابل نمایش</p>
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">رویدادهای پنهان از پیشنهادات کنار میروند</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Switch checked={draft.isActive} onCheckedChange={(v) => set({ isActive: v })} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={onClose}>انصراف</Button>
|
|
|
|
|
<Button disabled={!valid} onClick={() => draft && onSave(draft)}>ذخیره رویداد</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
function Loader({ className }: { className?: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={cn("space-y-3", className)}>
|
|
|
|
|
{[0, 1, 2, 3].map((i) => (
|
|
|
|
|
<Skeleton key={i} className="h-16 rounded-xl" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function LoaderRow() {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-3 py-1">
|
|
|
|
|
<Skeleton className="h-9 w-9 rounded-full" />
|
|
|
|
|
<div className="flex-1 space-y-2">
|
|
|
|
|
<Skeleton className="h-4 w-1/3" />
|
|
|
|
|
<Skeleton className="h-3 w-1/2" />
|
|
|
|
|
</div>
|
|
|
|
|
<Skeleton className="h-8 w-24 rounded-lg" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|