feat(frontend): add mentor chat and admin studio
Ship realtime mentor messaging and staff control panels for curriculum, users, and registration gates.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Calendar,
|
||||||
|
Eye,
|
||||||
|
Gamepad2,
|
||||||
|
Loader2,
|
||||||
|
Megaphone,
|
||||||
|
Route,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ id: "event", label: "رویداد", icon: Calendar, hint: "جلسه زنده، کارگاه، ددلاین" },
|
||||||
|
{ id: "progress", label: "پیشرفت", icon: TrendingUp, hint: "درس جدید، فصل بازشده" },
|
||||||
|
{ id: "gamification", label: "بازیسازی", icon: Gamepad2, hint: "جایزه، استریک، چالش" },
|
||||||
|
{ id: "mentor", label: "منتور", icon: Users, hint: "پیام مستقیم تیم منتور" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
{ path: "/unwrap", label: "نقشه مسیر" },
|
||||||
|
{ path: "/notifications", label: "اعلانها" },
|
||||||
|
{ path: "/leaderboard", label: "لیدربورد" },
|
||||||
|
{ path: "/cap", label: "داشبورد" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function PreviewCard({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
category,
|
||||||
|
route,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
category: string;
|
||||||
|
route: string;
|
||||||
|
}) {
|
||||||
|
const cat = CATEGORIES.find((c) => c.id === category);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-background p-3.5">
|
||||||
|
<div className="flex h-8 items-center gap-2">
|
||||||
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Megaphone className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{cat?.label ?? category}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2.5 text-sm font-black leading-snug">{title.trim() || "عنوان اطلاعیه"}</p>
|
||||||
|
<p className="mt-1 min-h-[2.5rem] text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{body.trim() || "متن پیام اینجا دیده میشود…"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2.5 truncate text-[10px] font-mono text-primary" dir="ltr">
|
||||||
|
→ {route || "/"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncePanel() {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [category, setCategory] = useState<string>("event");
|
||||||
|
const [route, setRoute] = useState("/unwrap");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [lastSent, setLastSent] = useState<{ title: string; recipients: number } | null>(null);
|
||||||
|
|
||||||
|
const titleOk = title.trim().length >= 3;
|
||||||
|
const bodyOk = body.trim().length >= 8;
|
||||||
|
const valid = titleOk && bodyOk;
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!valid) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await http.post<{ recipients: number }>("/api/admin/announce", {
|
||||||
|
title: title.trim(),
|
||||||
|
body: body.trim(),
|
||||||
|
category,
|
||||||
|
route,
|
||||||
|
});
|
||||||
|
toast.success(`اطلاعیه برای ${res.recipients} نفر ارسال شد`);
|
||||||
|
setLastSent({ title: title.trim(), recipients: res.recipients });
|
||||||
|
setTitle("");
|
||||||
|
setBody("");
|
||||||
|
setConfirmOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "ارسال اطلاعیه ممکن نشد"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-base">نگارش اعلان</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
به همه هنرجویان فعال میرسد و در مرکز اعلانها دیده میشود.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-bold">عنوان</Label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="مثلاً جلسه زنده هفته ۴ · پنجشنبه ۲۰:۰۰"
|
||||||
|
className={cn("h-11 rounded-2xl", !titleOk && title.length > 0 && "border-destructive")}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex h-5 items-center justify-between gap-3">
|
||||||
|
<Label className="text-sm font-bold">پیام</Label>
|
||||||
|
<span className="text-[11px] tabular-nums text-muted-foreground">{body.length}/۵۰۰</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value.slice(0, 500))}
|
||||||
|
rows={5}
|
||||||
|
maxLength={500}
|
||||||
|
className={cn(
|
||||||
|
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-sm leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
|
||||||
|
!bodyOk && body.length > 0 ? "border-destructive/50" : "border-input"
|
||||||
|
)}
|
||||||
|
placeholder="یک جملهٔ واضح و کوتاه — لینک یا زمان را حتماً بگویید…"
|
||||||
|
/>
|
||||||
|
{body.trim().length > 0 && body.trim().length < 8 && (
|
||||||
|
<p className="text-xs font-bold text-destructive">پیام حداقل چند کلمه باشد.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-bold">دسته</Label>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{CATEGORIES.map((c) => {
|
||||||
|
const active = category === c.id;
|
||||||
|
const Icon = c.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCategory(c.id)}
|
||||||
|
className={cn(
|
||||||
|
"grid h-[4.25rem] grid-cols-[2.25rem_minmax(0,1fr)] items-center gap-3 rounded-xl border px-3 text-right transition-colors",
|
||||||
|
active ? "border-primary bg-primary/10" : "border-border hover:border-primary/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-9 w-9 place-items-center rounded-xl",
|
||||||
|
active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-extrabold">{c.label}</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">{c.hint}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="flex h-5 items-center gap-1.5 text-sm font-bold">
|
||||||
|
<Route className="h-3.5 w-3.5" /> مقصد کلیک
|
||||||
|
</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ROUTES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRoute(r.path)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 items-center rounded-full border px-3 text-xs font-bold transition-colors",
|
||||||
|
route === r.path
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border hover:border-primary/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={route}
|
||||||
|
onChange={(e) => setRoute(e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
className="h-10 rounded-2xl font-mono text-xs"
|
||||||
|
placeholder="/unwrap"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="h-11 gap-2"
|
||||||
|
disabled={!valid}
|
||||||
|
onClick={() => setConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
پیشنمایش و ارسال
|
||||||
|
</Button>
|
||||||
|
{!valid && (
|
||||||
|
<p className="text-xs text-muted-foreground">عنوان و پیام را کامل کنید تا ادامه فعال شود.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-4 lg:sticky lg:top-24">
|
||||||
|
<Card className="border bg-muted/20">
|
||||||
|
<CardHeader className="space-y-1 pb-3">
|
||||||
|
<CardTitle className="flex h-6 items-center gap-2 text-sm">
|
||||||
|
<Eye className="h-3.5 w-3.5" /> پیشنمایش زنده
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-[11px] text-muted-foreground">همزمان با تایپ بهروز میشود.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PreviewCard title={title} body={body} category={category} route={route} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{lastSent && (
|
||||||
|
<Card className="border border-success/40 bg-success/5">
|
||||||
|
<CardContent className="space-y-1 pt-5 text-sm">
|
||||||
|
<p className="font-bold text-success">آخرین ارسال موفق</p>
|
||||||
|
<p className="truncate font-extrabold">{lastSent.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{lastSent.recipients} گیرنده</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<DialogContent className="max-w-md gap-0 overflow-hidden 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">تأیید ارسال</DialogTitle>
|
||||||
|
<DialogDescription>این پیشنمایش نهایی است؛ بعد از تأیید برای همه ارسال میشود.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<PreviewCard title={title} body={body} category={category} route={route} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="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={() => setConfirmOpen(false)} disabled={busy}>
|
||||||
|
بازگشت
|
||||||
|
</Button>
|
||||||
|
<Button className="h-10 gap-2" disabled={busy || !valid} onClick={() => void send()}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Bell className="h-4 w-4" />}
|
||||||
|
تأیید و ارسال
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Loader2, Trash2, Upload } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import type { RegistrationBlacklistAttempt, RegistrationPhoneBlacklist } from "@/lib/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import { formatFaNumber } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
ListPagination,
|
||||||
|
buildPageQuery,
|
||||||
|
type Paginated,
|
||||||
|
} from "@/components/admin/list-pagination";
|
||||||
|
import { ListToolbar } from "@/components/admin/list-toolbar";
|
||||||
|
import { useConfirm } from "@/components/admin/use-confirm";
|
||||||
|
|
||||||
|
const pathLabel: Record<string, string> = {
|
||||||
|
public: "عمومی",
|
||||||
|
invite: "دعوت",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportResult = {
|
||||||
|
inserted: number;
|
||||||
|
skipped: number;
|
||||||
|
invalid: number;
|
||||||
|
rejected: number;
|
||||||
|
rejectedPhones?: string[];
|
||||||
|
removedWhitelist?: number;
|
||||||
|
whitelistedConflicts?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BlacklistPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { confirm, dialog } = useConfirm();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState<number | null>(null);
|
||||||
|
const [listQ, setListQ] = useState("");
|
||||||
|
const [historyQ, setHistoryQ] = useState("");
|
||||||
|
const [listPage, setListPage] = useState(1);
|
||||||
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
|
const [pendingConflicts, setPendingConflicts] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setListPage(1);
|
||||||
|
}, [listQ]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHistoryPage(1);
|
||||||
|
}, [historyQ]);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "phone-blacklist", listQ, listPage],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams(buildPageQuery(listPage));
|
||||||
|
if (listQ.trim()) params.set("q", listQ.trim());
|
||||||
|
return http.get<Paginated<RegistrationPhoneBlacklist>>(
|
||||||
|
`/api/admin/phone-blacklist?${params.toString()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: historyData, isLoading: historyLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "phone-blacklist", "attempts", historyQ, historyPage],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams(buildPageQuery(historyPage));
|
||||||
|
if (historyQ.trim()) params.set("q", historyQ.trim());
|
||||||
|
return http.get<Paginated<RegistrationBlacklistAttempt>>(
|
||||||
|
`/api/admin/phone-blacklist/attempts?${params.toString()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = useMemo(() => data?.items ?? [], [data]);
|
||||||
|
const attempts = useMemo(() => historyData?.items ?? [], [historyData]);
|
||||||
|
const listTotal = data?.total ?? 0;
|
||||||
|
const listPageSize = data?.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||||
|
const historyTotal = historyData?.total ?? 0;
|
||||||
|
const historyPageSize = historyData?.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
const summarizeImport = (res: ImportResult) => {
|
||||||
|
const parts = [
|
||||||
|
`${formatFaNumber(res.inserted)} افزوده`,
|
||||||
|
res.skipped ? `${formatFaNumber(res.skipped)} تکراری` : null,
|
||||||
|
res.removedWhitelist ? `${formatFaNumber(res.removedWhitelist)} از فهرست مجاز حذف` : null,
|
||||||
|
res.invalid ? `${formatFaNumber(res.invalid)} نامعتبر` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
if (parts.length > 0) {
|
||||||
|
toast.success(parts.join(" · "));
|
||||||
|
}
|
||||||
|
if (res.rejected > 0) {
|
||||||
|
const phones = res.rejectedPhones ?? [];
|
||||||
|
const detail =
|
||||||
|
phones.length > 0
|
||||||
|
? ` (${phones.slice(0, 3).join("، ")}${phones.length > 3 ? "…" : ""})`
|
||||||
|
: "";
|
||||||
|
toast.error(
|
||||||
|
`${formatFaNumber(res.rejected)} شماره قبلاً ثبتنام شده و قابل انتقال به فهرست سیاه نیست${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const postImport = async (payload: {
|
||||||
|
text?: string;
|
||||||
|
phones?: string[];
|
||||||
|
resolveConflicts?: boolean;
|
||||||
|
}) => {
|
||||||
|
return http.post<ImportResult>("/api/admin/phone-blacklist/import", payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishImport = (res: ImportResult) => {
|
||||||
|
summarizeImport(res);
|
||||||
|
setText("");
|
||||||
|
setPendingConflicts([]);
|
||||||
|
setListPage(1);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "phone-blacklist"] });
|
||||||
|
if (res.removedWhitelist) {
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "phone-whitelist"] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPhones = async () => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
toast.error("حداقل یک شماره وارد کنید");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setPendingConflicts([]);
|
||||||
|
try {
|
||||||
|
const res = await postImport({ text });
|
||||||
|
const conflicts = res.whitelistedConflicts ?? [];
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
summarizeImport(res);
|
||||||
|
if (conflicts.length === 1) {
|
||||||
|
const phone = conflicts[0];
|
||||||
|
const ok = await confirm(
|
||||||
|
"این شماره در فهرست مجاز است",
|
||||||
|
`شماره ${phone} در فهرست مجاز است. حذف از فهرست مجاز و افزودن به فهرست سیاه؟`,
|
||||||
|
"حذف از فهرست مجاز و افزودن",
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
setPendingConflicts(conflicts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = await postImport({ phones: conflicts, resolveConflicts: true });
|
||||||
|
finishImport(resolved);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingConflicts(conflicts);
|
||||||
|
toast.error(
|
||||||
|
`${formatFaNumber(conflicts.length)} شماره در فهرست مجاز است — برای ادامه تعارض را حل کنید`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishImport(res);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveConflicts = async () => {
|
||||||
|
if (pendingConflicts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await postImport({ phones: pendingConflicts, resolveConflicts: true });
|
||||||
|
finishImport(res);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: number) => {
|
||||||
|
setDeleteBusy(id);
|
||||||
|
try {
|
||||||
|
await http.del(`/api/admin/phone-blacklist/${id}`);
|
||||||
|
toast.success("شماره از فهرست سیاه حذف شد");
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "phone-blacklist"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{dialog}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1 pb-3">
|
||||||
|
<CardTitle className="text-base">واردات شماره</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
این شمارهها همیشه رد میشوند — ثبتنام عمومی و دعوتنامه. اگر شماره در فهرست مجاز باشد، قبل
|
||||||
|
از انتقال تأیید میگیریم. فرمت: هر خط یا جدا با ویرگول.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bl-text">شمارهها</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bl-text"
|
||||||
|
className="min-h-[120px] text-left tracking-wide"
|
||||||
|
dir="ltr"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={"09121234567\n09129876543"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="gradient" disabled={busy} onClick={() => void importPhones()}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
واردات
|
||||||
|
</Button>
|
||||||
|
{pendingConflicts.length > 0 && (
|
||||||
|
<div className="space-y-3 rounded-xl border border-border bg-muted/30 p-4">
|
||||||
|
<p className="text-sm font-medium">این شماره در فهرست مجاز است</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
حذف از فهرست مجاز و افزودن به فهرست سیاه؟
|
||||||
|
</p>
|
||||||
|
<ul className="max-h-40 space-y-1 overflow-y-auto text-sm tracking-wide" dir="ltr">
|
||||||
|
{pendingConflicts.map((phone) => (
|
||||||
|
<li key={phone}>{phone}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="gradient"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void resolveConflicts()}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
حل تعارض و ادامه
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setPendingConflicts([])}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1 pb-3">
|
||||||
|
<CardTitle className="text-base">فهرست سیاه</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">جستجو و مدیریت شمارههای مسدود.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ListToolbar>
|
||||||
|
<Input
|
||||||
|
placeholder="جستجو با شماره…"
|
||||||
|
value={listQ}
|
||||||
|
onChange={(e) => setListQ(e.target.value)}
|
||||||
|
className="max-w-xs text-left tracking-wide"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{listQ && (
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={() => setListQ("")}>
|
||||||
|
پاک کردن
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ListToolbar>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && entries.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
هنوز شمارهای در فهرست سیاه نیست.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<p className="text-sm font-medium tracking-wide" dir="ltr">
|
||||||
|
{entry.phone}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
افزوده: {new Date(entry.createdAt).toLocaleString("fa-IR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteBusy === entry.id}
|
||||||
|
onClick={() => void remove(entry.id)}
|
||||||
|
>
|
||||||
|
{deleteBusy === entry.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ListPagination
|
||||||
|
page={listPage}
|
||||||
|
pageSize={listPageSize}
|
||||||
|
total={listTotal}
|
||||||
|
onPageChange={setListPage}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1 pb-3">
|
||||||
|
<CardTitle className="text-base">تاریخچه تلاش</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
تلاشهای ثبتنام با شمارهٔ مسدود — فقط برای ممیزی ثبت میشوند.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ListToolbar>
|
||||||
|
<Input
|
||||||
|
id="bl-hist-q"
|
||||||
|
value={historyQ}
|
||||||
|
onChange={(e) => setHistoryQ(e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
className="max-w-sm text-left tracking-wide"
|
||||||
|
placeholder="۰۹۱۲… یا نام کاربری"
|
||||||
|
/>
|
||||||
|
{historyQ && (
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={() => setHistoryQ("")}>
|
||||||
|
پاک کردن
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ListToolbar>
|
||||||
|
|
||||||
|
{historyLoading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!historyLoading && attempts.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
هنوز تلاشی ثبت نشده است.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{attempts.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
className="space-y-1 rounded-lg border border-border px-3 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="font-medium tracking-wide" dir="ltr">
|
||||||
|
{a.phone}
|
||||||
|
</p>
|
||||||
|
<span className="rounded-md bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||||
|
{pathLabel[a.path] ?? a.path}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{new Date(a.attemptedAt).toLocaleString("fa-IR")}
|
||||||
|
{a.ip ? ` · IP: ${a.ip}` : ""}
|
||||||
|
</p>
|
||||||
|
{(a.attemptedUsername || a.attemptedEmail) && (
|
||||||
|
<p className="truncate text-[11px] text-muted-foreground" dir="ltr">
|
||||||
|
{[a.attemptedUsername, a.attemptedEmail].filter(Boolean).join(" · ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ListPagination
|
||||||
|
page={historyPage}
|
||||||
|
pageSize={historyPageSize}
|
||||||
|
total={historyTotal}
|
||||||
|
onPageChange={setHistoryPage}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Plus, Trash2, Trophy } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
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 { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { JalaliDateTimePicker, isValidIsoRange } from "@/components/ui/jalali-datetime-picker";
|
||||||
|
import { useConfirm } from "@/components/admin/use-confirm";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import { formatFaNumber } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type AdminChallenge = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
targetXp: number;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChallengesPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { confirm, dialog } = useConfirm();
|
||||||
|
const [modal, setModal] = useState<AdminChallenge | null>(null);
|
||||||
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
|
queryKey: ["admin", "challenges"],
|
||||||
|
queryFn: () => http.get<{ challenges: AdminChallenge[] }>("/api/admin/challenges"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = async (c: AdminChallenge) => {
|
||||||
|
if (!isValidIsoRange(c.startsAt, c.endsAt)) {
|
||||||
|
toast.error("زمان پایان باید پس از زمان شروع باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!c.targetXp || c.targetXp <= 0) {
|
||||||
|
toast.error("هدف امتیاز باید بزرگتر از صفر باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { createdAt: _c, ...body } = c;
|
||||||
|
if (c.id) await http.put(`/api/admin/challenges/${c.id}`, body);
|
||||||
|
else await http.post("/api/admin/challenges", body);
|
||||||
|
setModal(null);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "challenges"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["challenges"] });
|
||||||
|
toast.success(c.id ? "چالش بهروزرسانی شد" : "چالش ساخته شد");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = async (c: AdminChallenge) => {
|
||||||
|
if (!(await confirm("حذف چالش", `چالش «${c.title}» حذف شود؟`))) return;
|
||||||
|
try {
|
||||||
|
await http.del(`/api/admin/challenges/${c.id}`);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "challenges"] });
|
||||||
|
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: "",
|
||||||
|
targetXp: 500,
|
||||||
|
startsAt: "",
|
||||||
|
endsAt: "",
|
||||||
|
isActive: true,
|
||||||
|
createdAt: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" /> چالش جدید
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[0, 1].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-20 rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
||||||
|
<p className="font-medium text-destructive">{toUserError(error)}</p>
|
||||||
|
<Button size="sm" variant="outline" className="mt-2" onClick={() => refetch()}>
|
||||||
|
تلاش دوباره
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{(data?.challenges ?? []).map((c) => (
|
||||||
|
<div key={c.id} className="rounded-md border border-border p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trophy className="h-4 w-4 text-gold" />
|
||||||
|
<Badge variant={c.isActive ? "success" : "secondary"}>{c.isActive ? "فعال" : "خاموش"}</Badge>
|
||||||
|
<Badge variant="accent">هدف {formatFaNumber(c.targetXp)} امتیاز</Badge>
|
||||||
|
<div className="ms-auto flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 px-2" onClick={() => setModal({ ...c })}>
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-destructive" onClick={() => del(c)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 font-medium">{c.title}</p>
|
||||||
|
<p className="line-clamp-2 text-xs text-muted-foreground">{c.description || "—"}</p>
|
||||||
|
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||||
|
{fmt(c.startsAt)} تا {fmt(c.endsAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!isLoading && !isError && (data?.challenges ?? []).length === 0 && (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">هنوز چالشی نیست.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<ChallengeDialog challenge={modal} onClose={() => setModal(null)} onSave={save} />
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChallengeDialog({
|
||||||
|
challenge,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
challenge: AdminChallenge | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (c: AdminChallenge) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<AdminChallenge | null>(challenge);
|
||||||
|
useEffect(() => setDraft(challenge), [challenge]);
|
||||||
|
const set = (patch: Partial<AdminChallenge>) => setDraft((d) => (d ? { ...d, ...patch } : d));
|
||||||
|
const valid =
|
||||||
|
!!draft?.title.trim() &&
|
||||||
|
!!draft?.startsAt &&
|
||||||
|
!!draft?.endsAt &&
|
||||||
|
(draft?.targetXp ?? 0) > 0 &&
|
||||||
|
isValidIsoRange(draft!.startsAt, draft!.endsAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!challenge} 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="مثلاً چالش ماه مهر" />
|
||||||
|
</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="space-y-1.5">
|
||||||
|
<Label>هدف امتیاز</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
dir="ltr"
|
||||||
|
min={1}
|
||||||
|
value={draft.targetXp}
|
||||||
|
onChange={(e) => set({ targetXp: Number(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Ban, Copy, Download, Link2, Loader2, Upload } from "lucide-react";
|
||||||
|
import { API_URL, http, refreshSession, throwFromResponse, toUserError } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth-store";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import { AdminSelect } from "@/components/admin/list-toolbar";
|
||||||
|
import { formatFaNumber } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ChatExportToken = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
expiresAt: string;
|
||||||
|
revokedAt?: string | null;
|
||||||
|
usedAt?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt?: string | null;
|
||||||
|
active: boolean;
|
||||||
|
token?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChatExportStats = {
|
||||||
|
conversationCount: number;
|
||||||
|
messageCount: number;
|
||||||
|
attachmentBytes: number;
|
||||||
|
bodyBytes: number;
|
||||||
|
estimatedBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatFaBytes(n: number): string {
|
||||||
|
if (!Number.isFinite(n) || n < 0) n = 0;
|
||||||
|
if (n < 1024) return `${formatFaNumber(n)} بایت`;
|
||||||
|
if (n < 1024 * 1024) return `${formatFaNumber(Math.round(n / 1024))} کیلوبایت`;
|
||||||
|
const mb = n / (1024 * 1024);
|
||||||
|
if (mb < 1024) {
|
||||||
|
const rounded = mb >= 10 ? Math.round(mb) : Number(mb.toFixed(1).replace(/\.0$/, ""));
|
||||||
|
return `${formatFaNumber(rounded, mb >= 10 ? 0 : 1)} مگابایت`;
|
||||||
|
}
|
||||||
|
const gb = mb / 1024;
|
||||||
|
const rounded = gb >= 10 ? Math.round(gb) : Number(gb.toFixed(1).replace(/\.0$/, ""));
|
||||||
|
return `${formatFaNumber(rounded, gb >= 10 ? 0 : 1)} گیگابایت`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFaDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat("fa-IR", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(iso));
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatExportPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [exportBusy, setExportBusy] = useState(false);
|
||||||
|
const [restoreBusy, setRestoreBusy] = useState(false);
|
||||||
|
const [createBusy, setCreateBusy] = useState(false);
|
||||||
|
const [revokeBusy, setRevokeBusy] = useState<number | null>(null);
|
||||||
|
const [expiresInHours, setExpiresInHours] = useState<1 | 24>(24);
|
||||||
|
const [lastCreatedURL, setLastCreatedURL] = useState("");
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "chat-export", "tokens"],
|
||||||
|
queryFn: () => http.get<{ items: ChatExportToken[] }>("/api/admin/chat-export/tokens"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statsData } = useQuery({
|
||||||
|
queryKey: ["admin", "chat-export", "stats"],
|
||||||
|
queryFn: () => http.get<{ stats: ChatExportStats }>("/api/admin/chat-export/stats"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = data?.items ?? [];
|
||||||
|
const stats = statsData?.stats;
|
||||||
|
|
||||||
|
const copyText = async (text: string, okMsg: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success(okMsg);
|
||||||
|
} catch {
|
||||||
|
toast.error("کپی ممکن نشد");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadExport = async () => {
|
||||||
|
setExportBusy(true);
|
||||||
|
try {
|
||||||
|
const doFetch = (token: string | null) =>
|
||||||
|
fetch(`${API_URL}/api/admin/chat-export`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
let res = await doFetch(useAuth.getState().accessToken);
|
||||||
|
if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
|
||||||
|
await refreshSession();
|
||||||
|
res = await doFetch(useAuth.getState().accessToken);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
await throwFromResponse(res, "دانلود خروجی گفتگوها ممکن نشد");
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `chat-export-${new Date().toISOString().slice(0, 10)}.zip`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast.success("آرشیو ZIP گفتگوها دانلود شد");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "دانلود خروجی گفتگوها ممکن نشد"));
|
||||||
|
} finally {
|
||||||
|
setExportBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreExport = async (file: File) => {
|
||||||
|
setRestoreBusy(true);
|
||||||
|
try {
|
||||||
|
const isZip =
|
||||||
|
file.name.toLowerCase().endsWith(".zip") ||
|
||||||
|
file.type === "application/zip" ||
|
||||||
|
file.type === "application/x-zip-compressed";
|
||||||
|
const doFetch = async (token: string | null) => {
|
||||||
|
if (isZip) {
|
||||||
|
return fetch(`${API_URL}/api/admin/chat-export/restore`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const text = await file.text();
|
||||||
|
const payload = JSON.parse(text) as unknown;
|
||||||
|
return fetch(`${API_URL}/api/admin/chat-export/restore`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let res = await doFetch(useAuth.getState().accessToken);
|
||||||
|
if (res.status === 401 && (useAuth.getState().refreshToken || useAuth.getState().user)) {
|
||||||
|
await refreshSession();
|
||||||
|
res = await doFetch(useAuth.getState().accessToken);
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
await throwFromResponse(res, "بازیابی خروجی گفتگوها ممکن نشد");
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { imported?: number };
|
||||||
|
toast.success(`${formatFaNumber(data.imported ?? 0)} پیام بازیابی شد`);
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin", "chat-export", "stats"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "بازیابی خروجی گفتگوها ممکن نشد"));
|
||||||
|
} finally {
|
||||||
|
setRestoreBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLink = async () => {
|
||||||
|
setCreateBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await http.post<{ token: ChatExportToken }>("/api/admin/chat-export/tokens", {
|
||||||
|
expiresInHours,
|
||||||
|
});
|
||||||
|
const url = res.token?.url ?? "";
|
||||||
|
setLastCreatedURL(url);
|
||||||
|
if (url) {
|
||||||
|
await copyText(url, "لینک دانلود ساخته و کپی شد");
|
||||||
|
} else {
|
||||||
|
toast.success("لینک دانلود ساخته شد");
|
||||||
|
}
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin", "chat-export", "tokens"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "ساخت لینک دانلود ممکن نشد"));
|
||||||
|
} finally {
|
||||||
|
setCreateBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revokeLink = async (id: number) => {
|
||||||
|
setRevokeBusy(id);
|
||||||
|
try {
|
||||||
|
await http.post(`/api/admin/chat-export/tokens/${id}/revoke`, {});
|
||||||
|
toast.success("لینک لغو شد");
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin", "chat-export", "tokens"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "لغو لینک ممکن نشد"));
|
||||||
|
} finally {
|
||||||
|
setRevokeBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">آرشیو گفتگوها</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
آرشیو فشردهٔ ZIP شامل <span className="font-medium text-foreground">manifest.json</span> و پوشهٔ{" "}
|
||||||
|
<span className="font-medium text-foreground">attachments/</span> برای همه گفتگوهای دانشجو↔منتور.
|
||||||
|
</p>
|
||||||
|
{stats ? (
|
||||||
|
<p className="rounded-lg border border-border bg-muted/30 px-3 py-2 text-sm leading-relaxed text-foreground">
|
||||||
|
کل تاریخچه تقریباً{" "}
|
||||||
|
<span className="font-medium">{formatFaBytes(stats.estimatedBytes)}</span> حجم دارد
|
||||||
|
{" "}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({formatFaNumber(stats.conversationCount)} گفتگو، {formatFaNumber(stats.messageCount)} پیام
|
||||||
|
{stats.attachmentBytes > 0
|
||||||
|
? `، حدود ${formatFaBytes(stats.attachmentBytes)} پیوست`
|
||||||
|
: ""}
|
||||||
|
). حجم فایل ZIP نزدیک همین مقدار است.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" onClick={() => void downloadExport()} disabled={exportBusy}>
|
||||||
|
{exportBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||||
|
دانلود آرشیو ZIP
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={restoreBusy}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
{restoreBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
بازیابی از ZIP یا JSON
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/zip,application/x-zip-compressed,.zip,application/json,.json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (f) void restoreExport(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">لینک دانلود خارجی</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
لینک یکبارمصرف با مهلت محدود بسازید؛ همان آرشیو ZIP را بدون ورود ادمین میدهد و پس از اولین دانلود باطل میشود (لغو دستی هم ممکن است).
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="export-ttl">مهلت اعتبار</Label>
|
||||||
|
<AdminSelect
|
||||||
|
id="export-ttl"
|
||||||
|
value={String(expiresInHours)}
|
||||||
|
onChange={(e) => setExpiresInHours(Number(e.target.value) === 1 ? 1 : 24)}
|
||||||
|
>
|
||||||
|
<option value="1">۱ ساعت</option>
|
||||||
|
<option value="24">۲۴ ساعت</option>
|
||||||
|
</AdminSelect>
|
||||||
|
</div>
|
||||||
|
<Button type="button" onClick={() => void createLink()} disabled={createBusy}>
|
||||||
|
{createBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4" />}
|
||||||
|
ایجاد لینک دانلود
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{lastCreatedURL ? (
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<code
|
||||||
|
dir="ltr"
|
||||||
|
className="min-w-0 flex-1 truncate rounded-lg border border-border bg-muted/40 px-3 py-2 text-xs font-mono"
|
||||||
|
>
|
||||||
|
{lastCreatedURL}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void copyText(lastCreatedURL, "لینک کپی شد")}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
کپی
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">لینکهای فعال و اخیر</p>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">در حال بارگذاری…</p>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">هنوز لینکی ساخته نشده است.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{tokens.map((tok) => (
|
||||||
|
<li
|
||||||
|
key={tok.id}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border border-border px-3 py-2 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{tok.active ? (
|
||||||
|
<Badge variant="secondary">فعال</Badge>
|
||||||
|
) : tok.revokedAt ? (
|
||||||
|
<Badge variant="destructive">لغو شده</Badge>
|
||||||
|
) : tok.usedAt ? (
|
||||||
|
<Badge variant="outline">استفاده شده</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">منقضی</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground" dir="ltr">
|
||||||
|
تا {formatFaDate(tok.expiresAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tok.url ? (
|
||||||
|
<code dir="ltr" className="block truncate text-xs font-mono text-muted-foreground">
|
||||||
|
{tok.url}
|
||||||
|
</code>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tok.url ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void copyText(tok.url!, "لینک کپی شد")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
کپی
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{tok.active ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={revokeBusy === tok.id}
|
||||||
|
onClick={() => void revokeLink(tok.id)}
|
||||||
|
>
|
||||||
|
{revokeBusy === tok.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Ban className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
لغو
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type ConfirmDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = "حذف",
|
||||||
|
onConfirm,
|
||||||
|
onOpenChange,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md rounded-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription className="leading-relaxed">{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Copy, Loader2, Plus, Ban, Pause, Play, Pencil } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import type { RegistrationInvite } 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 { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import { formatFaNumber } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
ListPagination,
|
||||||
|
buildPageQuery,
|
||||||
|
type Paginated,
|
||||||
|
} from "@/components/admin/list-pagination";
|
||||||
|
import { ListToolbar } from "@/components/admin/list-toolbar";
|
||||||
|
import { validateInviteLabel } from "@/lib/validation";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<RegistrationInvite["status"], string> = {
|
||||||
|
active: "فعال",
|
||||||
|
paused: "متوقف موقت",
|
||||||
|
revoked: "لغو شده",
|
||||||
|
exhausted: "تکمیل شده",
|
||||||
|
};
|
||||||
|
|
||||||
|
function inviteDisplayURL(inv: RegistrationInvite): string {
|
||||||
|
if (inv.url) return inv.url;
|
||||||
|
if (inv.token && typeof window !== "undefined") {
|
||||||
|
return `${window.location.origin}/register/invite/${inv.token}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeVariant(
|
||||||
|
status: RegistrationInvite["status"],
|
||||||
|
): "secondary" | "destructive" | "outline" {
|
||||||
|
if (status === "active") return "secondary";
|
||||||
|
if (status === "revoked") return "destructive";
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvitesPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [maxUses, setMaxUses] = useState(10);
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [expiresInDays, setExpiresInDays] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [actionBusy, setActionBusy] = useState<number | null>(null);
|
||||||
|
const [editing, setEditing] = useState<RegistrationInvite | null>(null);
|
||||||
|
const [editLabel, setEditLabel] = useState("");
|
||||||
|
const [editMaxUses, setEditMaxUses] = useState(1);
|
||||||
|
const [editExpiresInDays, setEditExpiresInDays] = useState("");
|
||||||
|
const [editClearExpires, setEditClearExpires] = useState(false);
|
||||||
|
const [editBusy, setEditBusy] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "invites", q, page],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams(buildPageQuery(page));
|
||||||
|
if (q.trim()) params.set("q", q.trim());
|
||||||
|
return http.get<Paginated<RegistrationInvite>>(`/api/admin/invites?${params.toString()}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const invites = useMemo(() => data?.items ?? [], [data]);
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const pageSize = data?.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (maxUses < 1 || maxUses > 10_000) {
|
||||||
|
toast.error("ظرفیت باید بین ۱ تا ۱۰۰۰۰ باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const labelErr = validateInviteLabel(label);
|
||||||
|
if (labelErr) {
|
||||||
|
toast.error(labelErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const body: { maxUses: number; label?: string; expiresInDays?: number } = {
|
||||||
|
maxUses,
|
||||||
|
};
|
||||||
|
if (label.trim()) body.label = label.trim();
|
||||||
|
const days = Number(expiresInDays);
|
||||||
|
if (expiresInDays.trim() && Number.isFinite(days) && days > 0) {
|
||||||
|
body.expiresInDays = Math.floor(days);
|
||||||
|
}
|
||||||
|
const res = await http.post<{ invite: RegistrationInvite }>("/api/admin/invites", body);
|
||||||
|
const url = inviteDisplayURL(res.invite);
|
||||||
|
if (url) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast.success("لینک ساخته و کپی شد");
|
||||||
|
} catch {
|
||||||
|
toast.success("لینک عضویت ساخته شد");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success("لینک عضویت ساخته شد");
|
||||||
|
}
|
||||||
|
setLabel("");
|
||||||
|
setExpiresInDays("");
|
||||||
|
setPage(1);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = async (inv: RegistrationInvite) => {
|
||||||
|
const url = inviteDisplayURL(inv);
|
||||||
|
if (!url) {
|
||||||
|
toast.error("آدرس لینک در دسترس نیست");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast.success("لینک کپی شد");
|
||||||
|
} catch {
|
||||||
|
toast.error("کپی ممکن نشد");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = async (id: number) => {
|
||||||
|
setActionBusy(id);
|
||||||
|
try {
|
||||||
|
await http.post(`/api/admin/invites/${id}/pause`, {});
|
||||||
|
toast.success("لینک موقتاً متوقف شد");
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resume = async (id: number) => {
|
||||||
|
setActionBusy(id);
|
||||||
|
try {
|
||||||
|
await http.post(`/api/admin/invites/${id}/resume`, {});
|
||||||
|
toast.success("لینک دوباره فعال شد");
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (id: number) => {
|
||||||
|
setActionBusy(id);
|
||||||
|
try {
|
||||||
|
await http.post(`/api/admin/invites/${id}/revoke`, {});
|
||||||
|
toast.success("لینک لغو شد");
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setActionBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (inv: RegistrationInvite) => {
|
||||||
|
setEditing(inv);
|
||||||
|
setEditLabel(inv.label ?? "");
|
||||||
|
setEditMaxUses(inv.maxUses);
|
||||||
|
setEditExpiresInDays("");
|
||||||
|
setEditClearExpires(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
if (editMaxUses < editing.usedCount) {
|
||||||
|
toast.error(`ظرفیت نمیتواند کمتر از ${formatFaNumber(editing.usedCount)} (استفادهشده) باشد`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editMaxUses < 1 || editMaxUses > 10_000) {
|
||||||
|
toast.error("ظرفیت باید بین ۱ تا ۱۰۰۰۰ باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const labelErr = validateInviteLabel(editLabel);
|
||||||
|
if (labelErr) {
|
||||||
|
toast.error(labelErr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditBusy(true);
|
||||||
|
try {
|
||||||
|
const body: {
|
||||||
|
label: string;
|
||||||
|
maxUses: number;
|
||||||
|
expiresInDays?: number;
|
||||||
|
clearExpires?: boolean;
|
||||||
|
} = {
|
||||||
|
label: editLabel.trim(),
|
||||||
|
maxUses: editMaxUses,
|
||||||
|
};
|
||||||
|
if (editClearExpires) {
|
||||||
|
body.clearExpires = true;
|
||||||
|
} else {
|
||||||
|
const days = Number(editExpiresInDays);
|
||||||
|
if (editExpiresInDays.trim() && Number.isFinite(days) && days > 0) {
|
||||||
|
body.expiresInDays = Math.floor(days);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await http.put(`/api/admin/invites/${editing.id}`, body);
|
||||||
|
toast.success("لینک بهروز شد");
|
||||||
|
setEditing(null);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "invites"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setEditBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1 pb-3">
|
||||||
|
<CardTitle className="text-base">ساخت لینک جدید</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
ظرفیت، برچسب و مهلت اختیاری — لینک را بعد از ساخت کپی کنید.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inv-max">ظرفیت (نفر)</Label>
|
||||||
|
<Input
|
||||||
|
id="inv-max"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
value={maxUses}
|
||||||
|
onChange={(e) => setMaxUses(Number(e.target.value) || 1)}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 sm:col-span-2">
|
||||||
|
<Label htmlFor="inv-label">برچسب (اختیاری)</Label>
|
||||||
|
<Input
|
||||||
|
id="inv-label"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="مثلاً دوره بهار"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="inv-exp">مهلت روز (اختیاری)</Label>
|
||||||
|
<Input
|
||||||
|
id="inv-exp"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={expiresInDays}
|
||||||
|
onChange={(e) => setExpiresInDays(e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-left"
|
||||||
|
placeholder="—"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="gradient" disabled={busy} onClick={() => void create()}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||||
|
ساخت لینک
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1 pb-3">
|
||||||
|
<CardTitle className="text-base">لینکهای عضویت</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">مدیریت، کپی و توقف لینکهای ساختهشده.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ListToolbar>
|
||||||
|
<Input
|
||||||
|
placeholder="جستجو با برچسب…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
{q && (
|
||||||
|
<Button type="button" size="sm" variant="ghost" onClick={() => setQ("")}>
|
||||||
|
پاک کردن
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ListToolbar>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && invites.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{q.trim() ? "نتیجهای یافت نشد." : "هنوز لینک عضویتی ساخته نشده است."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{invites.map((inv) => (
|
||||||
|
<div
|
||||||
|
key={inv.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{inv.label || `لینک #${formatFaNumber(inv.id)}`}
|
||||||
|
</p>
|
||||||
|
<Badge variant={statusBadgeVariant(inv.status)}>{STATUS_LABEL[inv.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
استفاده: {formatFaNumber(inv.usedCount)} / {formatFaNumber(inv.maxUses)}
|
||||||
|
{inv.status === "active" || inv.status === "paused"
|
||||||
|
? ` · باقیمانده: ${formatFaNumber(inv.remaining)}`
|
||||||
|
: null}
|
||||||
|
</p>
|
||||||
|
{inv.expiresAt && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
انقضا: {new Date(inv.expiresAt).toLocaleString("fa-IR")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{inv.token && (
|
||||||
|
<p className="truncate font-mono text-[11px] text-muted-foreground" dir="ltr">
|
||||||
|
{inviteDisplayURL(inv)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!inv.token && !inv.url}
|
||||||
|
onClick={() => void copy(inv)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
کپی
|
||||||
|
</Button>
|
||||||
|
{(inv.status === "active" || inv.status === "paused") && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={actionBusy === inv.id}
|
||||||
|
onClick={() => openEdit(inv)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{inv.status === "active" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={actionBusy === inv.id}
|
||||||
|
onClick={() => void pause(inv.id)}
|
||||||
|
>
|
||||||
|
{actionBusy === inv.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Pause className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
توقف موقت
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{inv.status === "paused" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={actionBusy === inv.id}
|
||||||
|
onClick={() => void resume(inv.id)}
|
||||||
|
>
|
||||||
|
{actionBusy === inv.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
ازسرگیری
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(inv.status === "active" || inv.status === "paused") && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={actionBusy === inv.id}
|
||||||
|
onClick={() => void revoke(inv.id)}
|
||||||
|
>
|
||||||
|
{actionBusy === inv.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Ban className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
لغو
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ListPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
||||||
|
<DialogContent className="max-w-md sm:rounded-2xl">
|
||||||
|
<DialogHeader className="text-start">
|
||||||
|
<DialogTitle>ویرایش لینک عضویت</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
ظرفیت و برچسب را تغییر دهید. ظرفیت نمیتواند کمتر از تعداد استفادهشده باشد.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{editing && (
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
استفادهشده: {formatFaNumber(editing.usedCount)} از {formatFaNumber(editing.maxUses)}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="edit-label">برچسب</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-label"
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
placeholder="مثلاً دوره بهار"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="edit-max">ظرفیت (نفر)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-max"
|
||||||
|
type="number"
|
||||||
|
min={editing.usedCount || 1}
|
||||||
|
max={10000}
|
||||||
|
value={editMaxUses}
|
||||||
|
onChange={(e) => setEditMaxUses(Number(e.target.value) || 1)}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="edit-exp">مهلت جدید (روز از الان — اختیاری)</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-exp"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={editExpiresInDays}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditExpiresInDays(e.target.value);
|
||||||
|
if (e.target.value.trim()) setEditClearExpires(false);
|
||||||
|
}}
|
||||||
|
dir="ltr"
|
||||||
|
className="text-left"
|
||||||
|
placeholder={editing.expiresAt ? "بدون تغییر" : "—"}
|
||||||
|
disabled={editClearExpires}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{editing.expiresAt && (
|
||||||
|
<label className="flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||||
|
<span>حذف مهلت انقضا</span>
|
||||||
|
<Switch
|
||||||
|
checked={editClearExpires}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
setEditClearExpires(v);
|
||||||
|
if (v) setEditExpiresInDays("");
|
||||||
|
}}
|
||||||
|
aria-label="حذف مهلت انقضا"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="gap-2 sm:space-x-0">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setEditing(null)} disabled={editBusy}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="gradient" disabled={editBusy} onClick={() => void saveEdit()}>
|
||||||
|
{editBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
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";
|
||||||
|
|
||||||
|
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 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],
|
||||||
|
queryFn: () =>
|
||||||
|
http.get<{ students: StudentLearningRow[] }>(
|
||||||
|
`/api/admin/learning/students?q=${encodeURIComponent(q)}`
|
||||||
|
),
|
||||||
|
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 overview = overviewQ.data?.overview;
|
||||||
|
const students = useMemo(() => {
|
||||||
|
let rows = [...(studentsQ.data?.students ?? [])];
|
||||||
|
if (filter === "active") rows = rows.filter((r) => r.isActive && !r.isLocked);
|
||||||
|
if (filter === "locked") rows = rows.filter((r) => r.isLocked);
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
switch (sort) {
|
||||||
|
case "progress":
|
||||||
|
return b.progressPct - a.progressPct;
|
||||||
|
case "streak":
|
||||||
|
return b.streakCurrent - a.streakCurrent;
|
||||||
|
case "activity": {
|
||||||
|
const at = a.lastActivityAt ? new Date(a.lastActivityAt).getTime() : 0;
|
||||||
|
const bt = b.lastActivityAt ? new Date(b.lastActivityAt).getTime() : 0;
|
||||||
|
return bt - at;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return b.xp - a.xp;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
}, [studentsQ.data?.students, filter, sort]);
|
||||||
|
|
||||||
|
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(students.length)} نفر · برای جزئیات روی ردیف بزنید
|
||||||
|
</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>
|
||||||
|
</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.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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formatFaNumber } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type Paginated<T> = {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export function buildPageQuery(page: number, pageSize = DEFAULT_PAGE_SIZE): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("page", String(page));
|
||||||
|
params.set("pageSize", String(pageSize));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListPagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
if (total <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
return (
|
||||||
|
<p className={className ?? "mt-2 pt-4 text-xs text-muted-foreground"}>
|
||||||
|
{formatFaNumber(total)} مورد
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
className ??
|
||||||
|
"mt-2 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4 text-sm text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
صفحه {formatFaNumber(page)} از {formatFaNumber(totalPages)}
|
||||||
|
{" · "}
|
||||||
|
{formatFaNumber(total)} مورد
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
قبلی
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
>
|
||||||
|
بعدی
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { ReactNode, SelectHTMLAttributes } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Shared chrome for admin list search / filter rows. */
|
||||||
|
export function ListToolbar({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-2.5 border-b border-border pb-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Native select styled to match Input height and radius. */
|
||||||
|
export function AdminSelect({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
"h-11 rounded-lg border border-input bg-card px-3 font-sans text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BookOpen,
|
||||||
|
CalendarPlus,
|
||||||
|
DatabaseBackup,
|
||||||
|
FileVideo,
|
||||||
|
GraduationCap,
|
||||||
|
HelpCircle,
|
||||||
|
Link2,
|
||||||
|
Megaphone,
|
||||||
|
Package,
|
||||||
|
Phone,
|
||||||
|
PhoneOff,
|
||||||
|
Settings2,
|
||||||
|
Trophy,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export type AdminTabId =
|
||||||
|
| "stats"
|
||||||
|
| "learning"
|
||||||
|
| "users"
|
||||||
|
| "orders"
|
||||||
|
| "registration"
|
||||||
|
| "invites"
|
||||||
|
| "whitelist"
|
||||||
|
| "blacklist"
|
||||||
|
| "curriculum"
|
||||||
|
| "questions"
|
||||||
|
| "videos"
|
||||||
|
| "events"
|
||||||
|
| "challenges"
|
||||||
|
| "announce"
|
||||||
|
| "chat-export"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
|
export type AdminNavItem = {
|
||||||
|
id: AdminTabId;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminNavGroup = {
|
||||||
|
title: string;
|
||||||
|
items: AdminNavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADMIN_NAV: AdminNavGroup[] = [
|
||||||
|
{
|
||||||
|
title: "نمای کلی",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "stats",
|
||||||
|
label: "نمای کلی",
|
||||||
|
description: "آمار سریع سامانه و یادداشتهای کاری ادمین.",
|
||||||
|
icon: Activity,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "افراد",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "learning",
|
||||||
|
label: "پیشرفت یادگیری",
|
||||||
|
description: "وضعیت پیشرفت، استریک و جزئیات مسیر هر هنرجو.",
|
||||||
|
icon: GraduationCap,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "users",
|
||||||
|
label: "کاربران",
|
||||||
|
description: "ساخت، ویرایش، قفل و مدیریت نقش حسابها.",
|
||||||
|
icon: Users,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "orders",
|
||||||
|
label: "سفارش فیزیکی",
|
||||||
|
description: "پیگیری درخواست و ارسال گواهینامه فیزیکی.",
|
||||||
|
icon: Package,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "ثبتنام و دسترسی",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "registration",
|
||||||
|
label: "ثبتنام عمومی",
|
||||||
|
description: "روشن/خاموش کردن ثبتنام آزاد در /register و نمای کلی دسترسی.",
|
||||||
|
icon: UserPlus,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "invites",
|
||||||
|
label: "لینک عضویت",
|
||||||
|
description: "ساخت و مدیریت لینکهای دعوت با ظرفیت و مهلت.",
|
||||||
|
icon: Link2,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "whitelist",
|
||||||
|
label: "فهرست مجاز",
|
||||||
|
description: "شمارههایی که وقتی اجبار فهرست مجاز روشن است اجازه ثبتنام عمومی دارند.",
|
||||||
|
icon: Phone,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "blacklist",
|
||||||
|
label: "فهرست سیاه",
|
||||||
|
description: "شمارههایی که همیشه رد میشوند — ثبتنام عمومی و دعوتنامه.",
|
||||||
|
icon: PhoneOff,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "محتوای دوره",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "curriculum",
|
||||||
|
label: "دروس",
|
||||||
|
description: "فصلها، درسها، ترتیب و درخت مهارت.",
|
||||||
|
icon: BookOpen,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "questions",
|
||||||
|
label: "سوالات",
|
||||||
|
description: "بانک سوالات چندگزینهای هر درس.",
|
||||||
|
icon: HelpCircle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "videos",
|
||||||
|
label: "ویدیوها",
|
||||||
|
description: "آپلود و مدیریت فایلهای ویدیوی دروس.",
|
||||||
|
icon: FileVideo,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "ارتباط",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "events",
|
||||||
|
label: "رویدادها",
|
||||||
|
description: "کارگاهها و جلسات قابلنمایش در مرکز هنرجو.",
|
||||||
|
icon: CalendarPlus,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "challenges",
|
||||||
|
label: "چالشها",
|
||||||
|
description: "چالش ماهانه با هدف امتیاز برای هنرجویان.",
|
||||||
|
icon: Trophy,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "announce",
|
||||||
|
label: "اطلاعیه",
|
||||||
|
description: "ارسال اعلان گروهی به هنرجویان فعال.",
|
||||||
|
icon: Megaphone,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chat-export",
|
||||||
|
label: "آرشیو گفتگوها",
|
||||||
|
description: "خروجی ساختاریافته و لینک دانلود موقت برای همه گفتگوهای پروژه.",
|
||||||
|
icon: DatabaseBackup,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "سایت",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "settings",
|
||||||
|
label: "تنظیمات سایت",
|
||||||
|
description: "حمایت مالی، گواهینامه فیزیکی و اسپانسرهای لندینگ.",
|
||||||
|
icon: Settings2,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findAdminNavItem(id: AdminTabId): AdminNavItem | undefined {
|
||||||
|
return ADMIN_NAV.flatMap((g) => g.items).find((i) => i.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultAdminTab(_isAdmin: boolean): AdminTabId {
|
||||||
|
return "learning";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidAdminTab(id: string, isAdmin: boolean): id is AdminTabId {
|
||||||
|
const item = findAdminNavItem(id as AdminTabId);
|
||||||
|
if (!item) return false;
|
||||||
|
if (item.adminOnly && !isAdmin) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function AdminPanelHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon: Icon,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
actions?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-3 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="flex items-center gap-2 text-lg font-bold tracking-tight sm:text-xl">
|
||||||
|
{Icon && (
|
||||||
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import type { CertificatePhysicalOrder, PhysicalCertSettings, PhysicalOrderStatus } 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 { toast } from "@/components/providers";
|
||||||
|
import { formatFaNumber } from "@/lib/utils";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<PhysicalOrderStatus, string> = {
|
||||||
|
requested: "درخواستشده",
|
||||||
|
paid: "پرداختشده",
|
||||||
|
shipped: "ارسالشده",
|
||||||
|
cancelled: "لغو",
|
||||||
|
};
|
||||||
|
|
||||||
|
const NEXT: Partial<Record<PhysicalOrderStatus, PhysicalOrderStatus>> = {
|
||||||
|
requested: "paid",
|
||||||
|
paid: "shipped",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PhysicalOrdersPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [notes, setNotes] = useState<Record<number, string>>({});
|
||||||
|
const [tracking, setTracking] = useState<Record<number, string>>({});
|
||||||
|
const [busy, setBusy] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "physical-orders", statusFilter],
|
||||||
|
queryFn: () =>
|
||||||
|
http.get<{ orders: CertificatePhysicalOrder[]; physical: PhysicalCertSettings }>(
|
||||||
|
`/api/admin/physical-orders?status=${encodeURIComponent(statusFilter)}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = async (id: number, status: PhysicalOrderStatus) => {
|
||||||
|
setBusy(id);
|
||||||
|
try {
|
||||||
|
await http.put(`/api/admin/physical-orders/${id}`, {
|
||||||
|
status,
|
||||||
|
note: notes[id] ?? "",
|
||||||
|
trackingCode: tracking[id] ?? "",
|
||||||
|
});
|
||||||
|
toast.success("وضعیت بهروزرسانی شد — دانشجو مطلع میشود");
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "physical-orders"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3 space-y-0">
|
||||||
|
<CardTitle className="text-base">فهرست سفارشها</CardTitle>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-lg border border-input bg-card px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">همه</option>
|
||||||
|
<option value="requested">درخواستشده</option>
|
||||||
|
<option value="paid">پرداختشده</option>
|
||||||
|
<option value="shipped">ارسالشده</option>
|
||||||
|
<option value="cancelled">لغو</option>
|
||||||
|
</select>
|
||||||
|
{data?.physical && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
قیمت: {formatFaNumber(data.physical.priceIrr)} ریال · پنجره:{" "}
|
||||||
|
{formatFaNumber(data.physical.windowDays)} روز
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(data?.orders ?? []).map((o) => (
|
||||||
|
<div key={o.id} className="space-y-2 rounded-md border border-border px-3 py-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{o.userName ?? `کاربر ${o.userId}`}</p>
|
||||||
|
<p className="text-xs text-muted-foreground" dir="ltr">
|
||||||
|
#{o.id} · cert {o.publicId}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
مهلت پنجره: {new Date(o.windowEndsAt).toLocaleString("fa-IR")}
|
||||||
|
</p>
|
||||||
|
{(o.address || o.city) && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{[o.recipientName, o.phone, o.city, o.postalCode].filter(Boolean).join(" · ")}
|
||||||
|
<br />
|
||||||
|
{o.address}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{o.trackingCode && (
|
||||||
|
<p className="text-xs font-mono" dir="ltr">
|
||||||
|
tracking: {o.trackingCode}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant={o.status === "cancelled" ? "destructive" : "secondary"}>
|
||||||
|
{STATUS_LABEL[o.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{o.note && <p className="text-xs text-muted-foreground">یادداشت: {o.note}</p>}
|
||||||
|
<Input
|
||||||
|
placeholder="یادداشت ادمین…"
|
||||||
|
value={notes[o.id] ?? ""}
|
||||||
|
onChange={(e) => setNotes((n) => ({ ...n, [o.id]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
{(o.status === "paid" || o.status === "shipped") && (
|
||||||
|
<Input
|
||||||
|
placeholder="کد پیگیری ارسال…"
|
||||||
|
value={tracking[o.id] ?? o.trackingCode ?? ""}
|
||||||
|
onChange={(e) => setTracking((t) => ({ ...t, [o.id]: e.target.value }))}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{NEXT[o.status] && (
|
||||||
|
<Button size="sm" disabled={busy === o.id} onClick={() => update(o.id, NEXT[o.status]!)}>
|
||||||
|
{busy === o.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
انتقال به {STATUS_LABEL[NEXT[o.status]!]}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{o.status !== "cancelled" && o.status !== "shipped" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy === o.id}
|
||||||
|
onClick={() => update(o.id, "cancelled")}
|
||||||
|
>
|
||||||
|
لغو
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isLoading && (data?.orders ?? []).length === 0 && (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">سفارشی نیست.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, ChevronDown, ChevronUp, Eye, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import type { Lesson, MCQQuestion } from "@/lib/types";
|
||||||
|
import { useConfirm } from "@/components/admin/use-confirm";
|
||||||
|
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 { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn, formatOptionLetter } from "@/lib/utils";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
function reorderArray<T>(arr: T[], from: number, to: number): T[] {
|
||||||
|
const next = arr.slice();
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ id: 1 as const, label: "متن و گزینهها" },
|
||||||
|
{ id: 2 as const, label: "پاسخ درست" },
|
||||||
|
{ id: 3 as const, label: "پیشنمایش نهایی" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function QuestionsPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { confirm, dialog } = useConfirm();
|
||||||
|
const [lessonId, setLessonId] = useState<number | null>(null);
|
||||||
|
const [modal, setModal] = useState<MCQQuestion | null>(null);
|
||||||
|
|
||||||
|
const { data: lessonsData } = useQuery({
|
||||||
|
queryKey: ["admin", "lessons"],
|
||||||
|
queryFn: () => http.get<{ lessons: Lesson[] }>("/api/admin/lessons"),
|
||||||
|
});
|
||||||
|
const lessons = [...(lessonsData?.lessons ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lessonId == null && lessons[0]) setLessonId(lessons[0].id);
|
||||||
|
}, [lessons, lessonId]);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "questions", lessonId],
|
||||||
|
queryFn: () => http.get<{ questions: MCQQuestion[] }>(`/api/admin/lessons/${lessonId}/questions`),
|
||||||
|
enabled: !!lessonId,
|
||||||
|
});
|
||||||
|
const qs = data?.questions ?? [];
|
||||||
|
const selected = lessons.find((l) => l.id === lessonId);
|
||||||
|
|
||||||
|
const save = async (qv: MCQQuestion) => {
|
||||||
|
try {
|
||||||
|
if (qv.id) await http.put(`/api/admin/questions/${qv.id}`, qv);
|
||||||
|
else await http.post(`/api/admin/lessons/${qv.lessonId}/questions`, qv);
|
||||||
|
setModal(null);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "questions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
||||||
|
toast.success(qv.id ? "سوال ذخیره شد" : "سوال اضافه شد");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "ذخیرهٔ سوال ممکن نشد"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = async (idx: number, delta: number) => {
|
||||||
|
const to = idx + delta;
|
||||||
|
if (to < 0 || to >= qs.length) return;
|
||||||
|
const next = reorderArray(qs, idx, to);
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < next.length; i++) {
|
||||||
|
if (next[i].position !== i + 1) {
|
||||||
|
await http.put(`/api/admin/questions/${next[i].id}`, { ...next[i], position: i + 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "questions"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "جابهجایی سوال ممکن نشد"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = async (q: MCQQuestion) => {
|
||||||
|
if (!(await confirm("حذف سوال", `سوال «${q.question.slice(0, 60)}…» حذف شود؟`))) return;
|
||||||
|
try {
|
||||||
|
await http.del(`/api/admin/questions/${q.id}`);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "questions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "stats"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "حذف سوال ممکن نشد"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openNew = () => {
|
||||||
|
if (!lessonId) return;
|
||||||
|
setModal({
|
||||||
|
id: 0,
|
||||||
|
lessonId,
|
||||||
|
position: qs.length + 1,
|
||||||
|
question: "",
|
||||||
|
options: ["", "", ""],
|
||||||
|
answerIndex: 0,
|
||||||
|
explanation: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{dialog}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1 pb-3">
|
||||||
|
<CardTitle className="text-base">انتخاب درس</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">سوالات آزمون متعلق به یک درساند؛ اول درس را انتخاب کنید.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{lessons.map((l) => {
|
||||||
|
const active = l.id === lessonId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLessonId(l.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-[4.25rem] w-40 shrink-0 flex-col justify-center rounded-xl border px-3 text-right transition-colors",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground shadow-offset-sm"
|
||||||
|
: "border-border bg-card hover:border-primary/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-[10px] font-bold opacity-70">L{l.id}</p>
|
||||||
|
<p className="truncate text-sm font-extrabold leading-snug">{l.title}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-4 space-y-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="truncate text-base">
|
||||||
|
سوالات {selected ? `· ${selected.title}` : ""}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{qs.length} سوال · تکپاسخی · ترتیب با فلشها
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="h-10 shrink-0 gap-1.5" disabled={!lessonId} onClick={openNew}>
|
||||||
|
<Plus className="h-4 w-4" /> سوال جدید
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-24 rounded-2xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{qs.map((q, i) => (
|
||||||
|
<div
|
||||||
|
key={q.id}
|
||||||
|
className="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-3 rounded-2xl border border-border bg-card px-4 py-3 sm:grid-cols-[2rem_minmax(0,1fr)_4.5rem_5.5rem]"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="mt-0.5 flex h-8 w-8 items-center justify-center rounded-xl p-0 font-black"
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</Badge>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-extrabold leading-snug">{q.question}</p>
|
||||||
|
<div className="mt-2 grid gap-1.5 sm:grid-cols-2">
|
||||||
|
{q.options.map((o, oi) => {
|
||||||
|
const isAns = oi === q.answerIndex;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={oi}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-9 items-center gap-2 rounded-xl border px-2.5 py-1.5 text-xs font-bold",
|
||||||
|
isAns
|
||||||
|
? "border-success/50 bg-success/10 text-success"
|
||||||
|
: "border-border text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="grid h-5 min-w-5 shrink-0 place-items-center rounded-full bg-background px-1 text-[9px] font-bold">
|
||||||
|
{formatOptionLetter(oi)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{o || "(خالی)"}</span>
|
||||||
|
{isAns && <Check className="h-3.5 w-3.5 shrink-0" />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hidden flex-col items-center gap-0.5 sm:flex">
|
||||||
|
<Button size="icon" variant="ghost" className="h-8 w-8" disabled={i === 0} onClick={() => move(i, -1)}>
|
||||||
|
<ChevronUp className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={i === qs.length - 1}
|
||||||
|
onClick={() => move(i, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-stretch gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => setModal(q)}>
|
||||||
|
ویرایش
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-full text-destructive"
|
||||||
|
onClick={() => del(q)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{qs.length === 0 && !isLoading && lessonId && (
|
||||||
|
<div className="rounded-2xl border border-dashed border-border px-6 py-12 text-center">
|
||||||
|
<p className="text-lg font-black">هنوز سوالی نیست</p>
|
||||||
|
<p className="mx-auto mt-2 max-w-sm text-sm text-muted-foreground">
|
||||||
|
حداقل دو گزینهٔ پر و یک پاسخ درست مشخص کنید تا آزمون این درس زنده شود.
|
||||||
|
</p>
|
||||||
|
<Button className="mt-5 gap-1.5" onClick={openNew}>
|
||||||
|
<Plus className="h-4 w-4" /> ساخت اولین سوال
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<QuestionDialog question={modal} onClose={() => setModal(null)} onSave={save} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuestionDialog({
|
||||||
|
question,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
}: {
|
||||||
|
question: MCQQuestion | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (q: MCQQuestion) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState<MCQQuestion | null>(question);
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft(question);
|
||||||
|
setStep(1);
|
||||||
|
}, [question]);
|
||||||
|
if (!draft) return null;
|
||||||
|
const set = (patch: Partial<MCQQuestion>) => setDraft((d) => (d ? { ...d, ...patch } : d));
|
||||||
|
const setOpt = (i: number, v: string) => set({ options: draft.options.map((o, idx) => (idx === i ? v : o)) });
|
||||||
|
const addOpt = () => {
|
||||||
|
if (draft.options.length >= 6) return;
|
||||||
|
set({ options: [...draft.options, ""] });
|
||||||
|
};
|
||||||
|
const delOpt = (i: number) => {
|
||||||
|
const opts = draft.options.filter((_, idx) => idx !== i);
|
||||||
|
let answerIndex = draft.answerIndex;
|
||||||
|
if (i < draft.answerIndex) {
|
||||||
|
answerIndex = draft.answerIndex - 1;
|
||||||
|
} else if (i === draft.answerIndex) {
|
||||||
|
answerIndex = Math.min(draft.answerIndex, Math.max(0, opts.length - 1));
|
||||||
|
}
|
||||||
|
set({ options: opts, answerIndex });
|
||||||
|
};
|
||||||
|
|
||||||
|
const clean = draft.options.map((o) => o.trim());
|
||||||
|
const questionOk = draft.question.trim().length > 3;
|
||||||
|
const allOptsFilled = clean.every(Boolean) && clean.length >= 2;
|
||||||
|
const answerOk = !!clean[draft.answerIndex];
|
||||||
|
const valid = questionOk && allOptsFilled && answerOk;
|
||||||
|
|
||||||
|
const canGo = (target: 1 | 2 | 3) => {
|
||||||
|
if (target === 1) return true;
|
||||||
|
if (target === 2) return questionOk && allOptsFilled;
|
||||||
|
return valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!question} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="flex max-h-[94vh] max-w-2xl flex-col gap-0 overflow-hidden p-0 sm:rounded-2xl">
|
||||||
|
<DialogHeader className="shrink-0 space-y-3 border-b border-border px-6 pb-4 pt-5 text-start">
|
||||||
|
<DialogTitle className="text-xl font-black">
|
||||||
|
{draft.id ? "ویرایش سوال" : "سوال جدید"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>قبل از ذخیره، پیشنمایش نهایی را مثل هنرجو ببینید.</DialogDescription>
|
||||||
|
<ol className="grid grid-cols-3 gap-2 pt-1">
|
||||||
|
{STEPS.map((s) => {
|
||||||
|
const active = step === s.id;
|
||||||
|
const done = step > s.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
disabled={!canGo(s.id)}
|
||||||
|
onClick={() => canGo(s.id) && setStep(s.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 items-center justify-center gap-1.5 rounded-xl border px-2 text-[11px] font-bold transition-colors sm:text-xs",
|
||||||
|
active
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: done
|
||||||
|
? "border-success/40 bg-success/10 text-success"
|
||||||
|
: "border-border bg-muted/40 text-muted-foreground disabled:opacity-40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="tabular-nums">{s.id}</span>
|
||||||
|
<span className="hidden truncate sm:inline">{s.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-bold">متن سوال</Label>
|
||||||
|
<textarea
|
||||||
|
value={draft.question}
|
||||||
|
onChange={(e) => set({ question: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
placeholder="مثلاً نتیجهٔ اجرای git status چیست؟"
|
||||||
|
className={cn(
|
||||||
|
"w-full resize-y rounded-xl border bg-background px-4 py-3 text-base font-medium leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary",
|
||||||
|
questionOk ? "border-input" : "border-destructive/40"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!questionOk && (
|
||||||
|
<p className="text-xs font-bold text-destructive">حداقل چند کلمه برای سوال بنویسید.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex h-8 items-center justify-between gap-3">
|
||||||
|
<Label className="text-sm font-bold">گزینهها ({draft.options.length}/۶)</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
onClick={addOpt}
|
||||||
|
disabled={draft.options.length >= 6}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> افزودن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{draft.options.map((opt, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="grid grid-cols-[2.25rem_minmax(0,1fr)_2.25rem] items-center gap-2 rounded-2xl border border-border bg-card p-2"
|
||||||
|
>
|
||||||
|
<span className="grid h-9 min-w-9 place-items-center rounded-xl bg-muted px-1 text-sm font-black">
|
||||||
|
{formatOptionLetter(i)}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={opt}
|
||||||
|
onChange={(e) => setOpt(i, e.target.value)}
|
||||||
|
placeholder={`متن گزینه ${formatOptionLetter(i)}`}
|
||||||
|
className="h-10 border-0 bg-transparent px-1 shadow-none focus-visible:ring-0"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 text-destructive"
|
||||||
|
disabled={draft.options.length <= 2}
|
||||||
|
onClick={() => delOpt(i)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!allOptsFilled && (
|
||||||
|
<p className="text-xs font-bold text-warning">
|
||||||
|
همهٔ گزینههای نمایشدادهشده باید پر باشند (حداقل ۲ تا).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-bold">توضیح بعد از اشتباه (اختیاری)</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.explanation}
|
||||||
|
onChange={(e) => set({ explanation: e.target.value })}
|
||||||
|
placeholder="چرا این پاسخ درست است؟"
|
||||||
|
className="h-11 rounded-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-muted-foreground">گزینهٔ درست را علامت بزنید.</p>
|
||||||
|
<p className="rounded-2xl border border-border bg-muted/30 px-4 py-3 text-base font-black leading-snug">
|
||||||
|
{draft.question}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{draft.options.map((o, i) => {
|
||||||
|
const isAns = i === draft.answerIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set({ answerIndex: i })}
|
||||||
|
className={cn(
|
||||||
|
"grid w-full grid-cols-[2.75rem_minmax(0,1fr)_1.25rem] items-center gap-3 rounded-xl border px-4 py-3 text-right text-sm font-bold transition-colors",
|
||||||
|
isAns
|
||||||
|
? "border-success bg-success/10 text-success"
|
||||||
|
: "border-border hover:border-primary/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid h-8 min-w-8 place-items-center rounded-full px-1 text-[11px] font-black",
|
||||||
|
isAns ? "bg-success text-success-foreground" : "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isAns ? <Check className="h-4 w-4" /> : formatOptionLetter(i)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 truncate">{o}</span>
|
||||||
|
<span className="flex justify-end">
|
||||||
|
{isAns && <Check className="h-4 w-4" />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!answerOk && (
|
||||||
|
<p className="text-xs font-bold text-destructive">پاسخ درست باید گزینهای با متن غیرخالی باشد.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-bold text-muted-foreground">
|
||||||
|
<Eye className="h-4 w-4 shrink-0" />
|
||||||
|
<span>پیشنمایش نهایی — همان چیزی که هنرجو در آزمون میبیند</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-3">
|
||||||
|
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-md p-0">
|
||||||
|
?
|
||||||
|
</Badge>
|
||||||
|
<p className="min-w-0 flex-1 font-medium leading-snug">{draft.question}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{draft.options.map((opt, oi) => {
|
||||||
|
const isAns = oi === draft.answerIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={oi}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-md border px-4 py-3 text-sm",
|
||||||
|
isAns
|
||||||
|
? "border-primary bg-primary/10 ring-1 ring-primary"
|
||||||
|
: "border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border px-1.5 text-[11px] font-semibold",
|
||||||
|
isAns
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatOptionLetter(oi)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">{opt}</span>
|
||||||
|
{isAns && (
|
||||||
|
<Badge variant="success" className="shrink-0 text-[10px]">
|
||||||
|
پاسخ درست
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{draft.explanation.trim() && (
|
||||||
|
<div className="border-t border-border bg-muted/20 px-4 py-3 text-xs leading-relaxed text-muted-foreground">
|
||||||
|
<span className="font-bold text-foreground">توضیح اشتباه: </span>
|
||||||
|
{draft.explanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="grid gap-2 rounded-2xl border border-dashed border-border px-4 py-3 text-xs text-muted-foreground sm:grid-cols-3">
|
||||||
|
<li className="flex items-center gap-1.5">
|
||||||
|
<Check className="h-3.5 w-3.5 text-success" /> {draft.options.length} گزینه
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-1.5">
|
||||||
|
<Check className="h-3.5 w-3.5 text-success" /> پاسخ {formatOptionLetter(draft.answerIndex)}
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-1.5">
|
||||||
|
<Check className="h-3.5 w-3.5 text-success" />
|
||||||
|
{draft.explanation.trim() ? "توضیح دارد" : "بدون توضیح"}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</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>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{step > 1 && (
|
||||||
|
<Button variant="outline" className="h-10" onClick={() => setStep((s) => (s - 1) as 1 | 2 | 3)}>
|
||||||
|
قبلی
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step < 3 ? (
|
||||||
|
<Button
|
||||||
|
className="h-10"
|
||||||
|
disabled={step === 1 ? !questionOk || !allOptsFilled : !answerOk}
|
||||||
|
onClick={() => setStep((s) => (s + 1) as 1 | 2 | 3)}
|
||||||
|
>
|
||||||
|
{step === 1 ? "بعدی · پاسخ درست" : "بعدی · پیشنمایش"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button disabled={!valid} className="h-10 gap-1.5" onClick={() => onSave(draft)}>
|
||||||
|
<Save className="h-4 w-4" /> تأیید و {draft.id ? "ذخیره" : "ساخت"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ArrowLeft, Link2, Loader2, Phone, PhoneOff, Save, ShieldAlert, UserPlus } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import type { AdminTabId } from "@/components/admin/nav";
|
||||||
|
|
||||||
|
type SettingsMap = Record<string, string>;
|
||||||
|
|
||||||
|
const RELATED: {
|
||||||
|
id: AdminTabId;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
icon: typeof Link2;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: "invites",
|
||||||
|
label: "لینک عضویت",
|
||||||
|
hint: "دعوت با ظرفیت و مهلت، بدون نیاز به فهرست مجاز",
|
||||||
|
icon: Link2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "whitelist",
|
||||||
|
label: "فهرست مجاز",
|
||||||
|
hint: "وقتی اجبار فهرست مجاز روشن باشد، فقط این شمارهها ثبتنام عمومی میکنند",
|
||||||
|
icon: Phone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "blacklist",
|
||||||
|
label: "فهرست سیاه",
|
||||||
|
hint: "همیشه رد — برای ثبتنام عمومی و دعوتنامه",
|
||||||
|
icon: PhoneOff,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RegistrationPanel({ onNavigate }: { onNavigate: (tab: AdminTabId) => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [requireWhitelist, setRequireWhitelist] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "settings"],
|
||||||
|
queryFn: () => http.get<{ settings: SettingsMap }>("/api/admin/settings"),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.settings || dirty) return;
|
||||||
|
setEnabled((data.settings.registration_enabled ?? "false") === "true");
|
||||||
|
setRequireWhitelist((data.settings.registration_require_whitelist ?? "true") === "true");
|
||||||
|
}, [data, dirty]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!data?.settings) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await http.put("/api/admin/settings", {
|
||||||
|
settings: {
|
||||||
|
...data.settings,
|
||||||
|
registration_enabled: enabled ? "true" : "false",
|
||||||
|
registration_require_whitelist: requireWhitelist ? "true" : "false",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success("تنظیمات ثبتنام ذخیره شد");
|
||||||
|
setDirty(false);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["auth", "register-status"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<UserPlus className="h-4 w-4" /> دسترسی عمومی
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
ثبتنام در /register را روشن کنید و مشخص کنید آیا شماره باید حتماً در فهرست مجاز باشد.
|
||||||
|
فهرست سیاه همیشه رد میکند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" disabled={busy || !dirty} onClick={() => void save()}>
|
||||||
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<label className="flex items-center justify-between gap-4 rounded-lg border border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold">ثبتنام عمومی فعال باشد</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{enabled
|
||||||
|
? "صفحه ثبتنام عمومی در دسترس است"
|
||||||
|
: "فقط لینک عضویت یا ورود حسابهای موجود"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
setEnabled(v);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
aria-label="ثبتنام عمومی"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-4 rounded-lg border border-border px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold">اجبار فهرست مجاز</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{requireWhitelist
|
||||||
|
? "فقط شمارههای آزاد در فهرست مجاز میتوانند ثبتنام عمومی کنند"
|
||||||
|
: "هر شمارهای (بهجز فهرست سیاه) میتواند ثبتنام کند"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={requireWhitelist}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
setRequireWhitelist(v);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
aria-label="اجبار فهرست مجاز"
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<p>
|
||||||
|
<span className="font-bold text-foreground">فهرست سیاه</span> همیشه اعمال میشود؛ حتی اگر اجبار
|
||||||
|
فهرست مجاز خاموش باشد یا ثبتنام از لینک دعوت باشد.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 px-0.5 text-xs font-bold text-muted-foreground">ابزارهای مرتبط</p>
|
||||||
|
<div className="grid gap-2.5 sm:grid-cols-3">
|
||||||
|
{RELATED.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onNavigate(item.id)}
|
||||||
|
className="group flex flex-col gap-2 rounded-xl border border-border bg-card p-3.5 text-start transition-colors hover:border-primary/40 hover:bg-muted/40"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-2">
|
||||||
|
<span className="grid h-9 w-9 place-items-center rounded-xl bg-primary/10 text-primary">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<ArrowLeft className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold">{item.label}</span>
|
||||||
|
<span className="text-xs leading-relaxed text-muted-foreground">{item.hint}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { HeartHandshake, Loader2, Package, Save, Settings2, Sparkles } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import type { AdminTabId } from "@/components/admin/nav";
|
||||||
|
|
||||||
|
type SettingsMap = Record<string, string>;
|
||||||
|
|
||||||
|
type SponsorDraft = { name: string; url: string; blurb: string };
|
||||||
|
|
||||||
|
function SectionHeading({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
icon: typeof Settings2;
|
||||||
|
title: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 border-b border-border pb-2">
|
||||||
|
<p className="flex items-center gap-2 text-sm font-bold">
|
||||||
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteSettingsPanel({
|
||||||
|
onNavigate,
|
||||||
|
}: {
|
||||||
|
onNavigate?: (tab: AdminTabId) => void;
|
||||||
|
}) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [draft, setDraft] = useState<SettingsMap>({});
|
||||||
|
const [sponsors, setSponsors] = useState<SponsorDraft[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "settings"],
|
||||||
|
queryFn: () => http.get<{ settings: SettingsMap }>("/api/admin/settings"),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data?.settings) return;
|
||||||
|
setDraft(data.settings);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.settings.sponsors || "[]") as SponsorDraft[];
|
||||||
|
setSponsors(
|
||||||
|
Array.isArray(parsed)
|
||||||
|
? parsed.map((s) => ({
|
||||||
|
name: s.name ?? "",
|
||||||
|
url: s.url ?? "",
|
||||||
|
blurb: s.blurb ?? "",
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setSponsors([]);
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const set = (key: string, value: string) => setDraft((d) => ({ ...d, [key]: value }));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if ((draft.donation_note ?? "").trim().length > 2000) {
|
||||||
|
toast.error("یادداشت دونیت خیلی طولانی است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const price = Number(draft.physical_cert_price_irr);
|
||||||
|
const days = Number(draft.physical_cert_window_days);
|
||||||
|
if (!Number.isFinite(price) || price < 0) {
|
||||||
|
toast.error("قیمت گواهینامه نامعتبر است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(days) || days < 1 || days > 3650) {
|
||||||
|
toast.error("مهلت گواهینامه باید بین ۱ تا ۳۶۵۰ روز باشد");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sponsors.length > 20) {
|
||||||
|
toast.error("حداکثر ۲۰ اسپانسر مجاز است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const s of sponsors) {
|
||||||
|
if (!s.name.trim()) continue;
|
||||||
|
if ([...s.name.trim()].length > 100) {
|
||||||
|
toast.error("نام اسپانسر خیلی طولانی است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ([...s.blurb.trim()].length > 300) {
|
||||||
|
toast.error("توضیح اسپانسر خیلی طولانی است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ([...s.url.trim()].length > 500) {
|
||||||
|
toast.error("لینک اسپانسر خیلی طولانی است");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const settings = {
|
||||||
|
...draft,
|
||||||
|
sponsors: JSON.stringify(
|
||||||
|
sponsors
|
||||||
|
.filter((s) => s.name.trim())
|
||||||
|
.map((s) => ({
|
||||||
|
name: s.name.trim(),
|
||||||
|
url: s.url.trim(),
|
||||||
|
blurb: s.blurb.trim(),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await http.put("/api/admin/settings", { settings });
|
||||||
|
toast.success("تنظیمات ذخیره شد");
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["community"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["sponsors"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["auth", "register-status"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle className="text-base">گزینههای قابل ذخیره</CardTitle>
|
||||||
|
<Button size="sm" disabled={busy} onClick={() => void save()}>
|
||||||
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||||
|
ذخیره
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
{onNavigate && (
|
||||||
|
<section className="rounded-xl border border-dashed border-border bg-muted/30 px-4 py-3">
|
||||||
|
<p className="text-sm font-bold">ثبتنام و دسترسی</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
روشن/خاموش کردن ثبتنام عمومی، لینک عضویت، فهرست مجاز و فهرست سیاه در بخش جداگانه
|
||||||
|
هستند.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={() => onNavigate("registration")}
|
||||||
|
>
|
||||||
|
رفتن به ثبتنام و دسترسی
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<SectionHeading
|
||||||
|
icon={HeartHandshake}
|
||||||
|
title="حمایت / دونیت"
|
||||||
|
hint="متن راهنما برای گفتگو با تیم دربارهٔ حمایت مالی"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span>فعال باشد</span>
|
||||||
|
<Switch
|
||||||
|
checked={(draft.donation_enabled ?? "true") === "true"}
|
||||||
|
onCheckedChange={(v) => set("donation_enabled", v ? "true" : "false")}
|
||||||
|
aria-label="حمایت مالی"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[96px]"
|
||||||
|
value={draft.donation_note ?? ""}
|
||||||
|
onChange={(e) => set("donation_note", e.target.value)}
|
||||||
|
placeholder="متن راهنما برای دانشجو"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<SectionHeading
|
||||||
|
icon={Package}
|
||||||
|
title="گواهینامه فیزیکی"
|
||||||
|
hint="قیمت و مهلت سفارش گواهینامه چاپی پس از اتمام دوره"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span>فعال باشد</span>
|
||||||
|
<Switch
|
||||||
|
checked={(draft.physical_cert_enabled ?? "true") === "true"}
|
||||||
|
onCheckedChange={(v) => set("physical_cert_enabled", v ? "true" : "false")}
|
||||||
|
aria-label="گواهینامه فیزیکی"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="قیمت (ریال)"
|
||||||
|
value={draft.physical_cert_price_irr ?? ""}
|
||||||
|
onChange={(e) => set("physical_cert_price_irr", e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="مهلت (روز)"
|
||||||
|
value={draft.physical_cert_window_days ?? ""}
|
||||||
|
onChange={(e) => set("physical_cert_window_days", e.target.value)}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-end justify-between gap-3">
|
||||||
|
<SectionHeading
|
||||||
|
icon={Sparkles}
|
||||||
|
title="اسپانسرهای لندینگ"
|
||||||
|
hint="نام، لینک و توضیح کوتاه روی صفحه اصلی"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mb-2 shrink-0"
|
||||||
|
onClick={() => setSponsors((s) => [...s, { name: "", url: "", blurb: "" }])}
|
||||||
|
>
|
||||||
|
افزودن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{sponsors.length === 0 && (
|
||||||
|
<p className="rounded-xl border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
هنوز اسپانسری ثبت نشده. با «افزودن» شروع کنید.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sponsors.map((sp, i) => (
|
||||||
|
<div key={i} className="space-y-2 rounded-xl border border-border p-3">
|
||||||
|
<Input
|
||||||
|
placeholder="نام"
|
||||||
|
value={sp.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSponsors((list) => list.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="https://…"
|
||||||
|
value={sp.url}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSponsors((list) => list.map((x, j) => (j === i ? { ...x, url: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="توضیح کوتاه"
|
||||||
|
value={sp.blurb}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSponsors((list) => list.map((x, j) => (j === i ? { ...x, blurb: e.target.value } : x)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => setSponsors((list) => list.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
رمزها، دیتابیس و JWT همچنان در فایل محیط (.env) میمانند — اینجا فقط تنظیمات قابلتغییر
|
||||||
|
روزمره است.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
BookOpen,
|
||||||
|
CalendarPlus,
|
||||||
|
Eraser,
|
||||||
|
Pencil,
|
||||||
|
Trophy,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { http } from "@/lib/api";
|
||||||
|
import type { AdminStats } from "@/lib/types";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SKETCH_KEY = "pargar.admin.statsSketch";
|
||||||
|
const NOTES_KEY = "pargar.admin.statsNotes";
|
||||||
|
|
||||||
|
/** Ink is always stored black; CSS invert makes it white in dark mode. */
|
||||||
|
const SKETCH_INK = "#000000";
|
||||||
|
|
||||||
|
export function StatsPanel() {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "stats"],
|
||||||
|
queryFn: () => http.get<{ stats: AdminStats }>("/api/admin/stats"),
|
||||||
|
});
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const drawing = useRef(false);
|
||||||
|
const [ink, setInk] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotes(localStorage.getItem(NOTES_KEY) ?? "");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = canvas.clientWidth;
|
||||||
|
const h = 220;
|
||||||
|
if (w <= 0) return;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.lineJoin = "round";
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.strokeStyle = SKETCH_INK;
|
||||||
|
const saved = localStorage.getItem(SKETCH_KEY);
|
||||||
|
if (saved) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
img.src = saved;
|
||||||
|
}
|
||||||
|
}, [isLoading]);
|
||||||
|
|
||||||
|
const persistCanvas = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SKETCH_KEY, canvas.toDataURL("image/png"));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pos = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const rect = canvasRef.current!.getBoundingClientRect();
|
||||||
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas?.getContext("2d");
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
drawing.current = true;
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
const p = pos(e);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
if (ink) {
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.strokeStyle = SKETCH_INK;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
} else {
|
||||||
|
ctx.globalCompositeOperation = "destination-out";
|
||||||
|
ctx.strokeStyle = "rgba(0,0,0,1)";
|
||||||
|
ctx.lineWidth = 16;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!drawing.current) return;
|
||||||
|
const ctx = canvasRef.current?.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
const p = pos(e);
|
||||||
|
ctx.lineTo(p.x, p.y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(p.x, p.y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
if (!drawing.current) return;
|
||||||
|
drawing.current = false;
|
||||||
|
persistCanvas();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSketch = () => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas?.getContext("2d");
|
||||||
|
if (!canvas || !ctx) return;
|
||||||
|
ctx.save();
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.restore();
|
||||||
|
localStorage.removeItem(SKETCH_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="rounded-xl border border-border/70 bg-card p-5">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||||
|
<Skeleton className="mt-3 h-6 w-12" />
|
||||||
|
<Skeleton className="mt-1 h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = data?.stats;
|
||||||
|
const cards = [
|
||||||
|
{ label: "هنرجویان", value: s?.users ?? 0, icon: Users, klass: "text-primary bg-primary/10", hint: "حسابهای فعال در سامانه" },
|
||||||
|
{ label: "درسها", value: s?.lessons ?? 0, icon: BookOpen, klass: "text-secondary bg-secondary/10", hint: "درسهای ثبتشده در درخت" },
|
||||||
|
{ label: "سوالات", value: s?.questions ?? 0, icon: Activity, klass: "text-accent bg-accent/10", hint: "بانک سوالات آزمون" },
|
||||||
|
{ label: "رویدادها", value: s?.events ?? 0, icon: CalendarPlus, klass: "text-gold bg-gold/10", hint: "جلسات و کارگاهها" },
|
||||||
|
{ label: "تکمیلها", value: s?.completions ?? 0, icon: Trophy, klass: "text-success bg-success/10", hint: "قبولی آزمون درسها" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Card key={c.label} className="overflow-hidden border">
|
||||||
|
<CardContent className="pt-5">
|
||||||
|
<div className={cn("mb-3 flex h-11 w-11 items-center justify-center rounded-2xl", c.klass)}>
|
||||||
|
<c.icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-black tabular-nums">{c.value}</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>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0 pb-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">بوم یادداشت دستی</CardTitle>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">اسکچ و ایدههای جلسه را اینجا بکشید؛ روی همین مرورگر ذخیره میشود.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={ink ? "default" : "outline"}
|
||||||
|
className="h-8 gap-1"
|
||||||
|
onClick={() => setInk(true)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" /> قلم
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={!ink ? "default" : "outline"}
|
||||||
|
className="h-8 gap-1"
|
||||||
|
onClick={() => setInk(false)}
|
||||||
|
>
|
||||||
|
<Eraser className="h-3.5 w-3.5" /> پاککن
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-8" onClick={clearSketch}>
|
||||||
|
پاک کردن
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-dashed border-border bg-muted/20">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="h-[220px] w-full touch-none dark:invert"
|
||||||
|
onPointerDown={onDown}
|
||||||
|
onPointerMove={onMove}
|
||||||
|
onPointerUp={onUp}
|
||||||
|
onPointerLeave={onUp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">یادداشت متنی</CardTitle>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">چکلیست یا نکات عملیاتی برای تیم منتور.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNotes(e.target.value);
|
||||||
|
localStorage.setItem(NOTES_KEY, e.target.value);
|
||||||
|
}}
|
||||||
|
rows={9}
|
||||||
|
placeholder="مثلاً: این هفته ویدیوی فصل ۲ را آپلود کنیم…"
|
||||||
|
className="min-h-[200px] resize-y"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { ConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
|
|
||||||
|
type Pending = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
resolve: (ok: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useConfirm() {
|
||||||
|
const [pending, setPending] = useState<Pending | null>(null);
|
||||||
|
const settledRef = useRef(false);
|
||||||
|
|
||||||
|
const confirm = useCallback((title: string, description: string, confirmLabel?: string) => {
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
settledRef.current = false;
|
||||||
|
setPending({ title, description, confirmLabel, resolve });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const settle = useCallback((ok: boolean) => {
|
||||||
|
if (settledRef.current || !pending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settledRef.current = true;
|
||||||
|
const p = pending;
|
||||||
|
setPending(null);
|
||||||
|
p.resolve(ok);
|
||||||
|
}, [pending]);
|
||||||
|
|
||||||
|
const dialog = (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!pending}
|
||||||
|
title={pending?.title ?? ""}
|
||||||
|
description={pending?.description ?? ""}
|
||||||
|
confirmLabel={pending?.confirmLabel}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
settle(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={() => settle(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { confirm, dialog };
|
||||||
|
}
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
Eye,
|
||||||
|
Film,
|
||||||
|
Link2,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { http, signedMediaUrl, toUserError } from "@/lib/api";
|
||||||
|
import { uploadVideo, validateVideoFile } from "@/lib/upload";
|
||||||
|
import type { Lesson } from "@/lib/types";
|
||||||
|
import { useConfirm } from "@/components/admin/use-confirm";
|
||||||
|
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 { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
type VideoItem = { key: string; size: number; mime?: string; lastModified?: string };
|
||||||
|
|
||||||
|
function formatBytes(n: number) {
|
||||||
|
if (!n) return "—";
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLabel(key: string) {
|
||||||
|
const ext = key.toLowerCase().endsWith(".webm") ? "WebM" : "MP4";
|
||||||
|
return ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoThumb({
|
||||||
|
videoKey,
|
||||||
|
className,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
videoKey: string;
|
||||||
|
className?: string;
|
||||||
|
onOpen?: () => void;
|
||||||
|
}) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setState("loading");
|
||||||
|
setUrl(null);
|
||||||
|
signedMediaUrl(videoKey)
|
||||||
|
.then((u) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setUrl(u);
|
||||||
|
setState("ready");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setState("error");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [videoKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
className={cn(
|
||||||
|
"group relative aspect-video w-full overflow-hidden rounded-xl border border-border bg-muted/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title="پیشنمایش"
|
||||||
|
>
|
||||||
|
{state === "loading" && (
|
||||||
|
<span className="absolute inset-0 grid place-items-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state === "error" && (
|
||||||
|
<span className="absolute inset-0 grid place-items-center gap-1 text-muted-foreground">
|
||||||
|
<Film className="h-5 w-5" />
|
||||||
|
<span className="text-[10px] font-bold">پیشنمایش نیست</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{url && state === "ready" && (
|
||||||
|
<video
|
||||||
|
src={url}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
onError={() => setState("error")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="absolute inset-0 grid place-items-center bg-black/0 transition-colors group-hover:bg-black/35">
|
||||||
|
<span className="grid h-10 w-10 place-items-center rounded-full bg-background/90 text-foreground opacity-80 shadow-sm group-hover:opacity-100">
|
||||||
|
<Play className="h-4 w-4 fill-current" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoPreviewDialog({
|
||||||
|
videoKey,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
videoKey: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoKey) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setUrl(null);
|
||||||
|
setError(null);
|
||||||
|
signedMediaUrl(videoKey)
|
||||||
|
.then((u) => {
|
||||||
|
if (!cancelled) setUrl(u);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setError(toUserError(e, "پیشنمایش ویدیو ممکن نشد"));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [videoKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={!!videoKey} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl gap-0 overflow-hidden p-0 sm:rounded-2xl">
|
||||||
|
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start">
|
||||||
|
<DialogTitle className="text-base font-black">پیشنمایش ویدیو</DialogTitle>
|
||||||
|
<DialogDescription className="truncate font-mono text-xs" dir="ltr">
|
||||||
|
{videoKey}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="bg-black px-0 py-0">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 px-6 py-10 text-sm text-destructive">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" /> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && !url && (
|
||||||
|
<div className="grid h-56 place-items-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{url && (
|
||||||
|
<video src={url} controls playsInline className="max-h-[70vh] w-full bg-black" autoPlay />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="border-t border-border px-6 py-3 sm:justify-end">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
بستن
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideosPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { confirm, dialog } = useConfirm();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [attachKey, setAttachKey] = useState<string | null>(null);
|
||||||
|
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["admin", "videos"],
|
||||||
|
queryFn: () => http.get<{ videos: VideoItem[] }>("/api/admin/videos"),
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
const videos = data?.videos ?? [];
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: lessonsData,
|
||||||
|
isError: lessonsError,
|
||||||
|
error: lessonsErr,
|
||||||
|
refetch: refetchLessons,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["admin", "lessons"],
|
||||||
|
queryFn: () => http.get<{ lessons: Lesson[] }>("/api/admin/lessons"),
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
const lessons = lessonsData?.lessons ?? [];
|
||||||
|
|
||||||
|
const usedByKey = useMemo(() => {
|
||||||
|
const map = new Map<string, Lesson[]>();
|
||||||
|
for (const l of lessons) {
|
||||||
|
if (!l.videoKey) continue;
|
||||||
|
const arr = map.get(l.videoKey) ?? [];
|
||||||
|
arr.push(l);
|
||||||
|
map.set(l.videoKey, arr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [lessons]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase();
|
||||||
|
if (!term) return videos;
|
||||||
|
return videos.filter((v) => v.key.toLowerCase().includes(term));
|
||||||
|
}, [videos, q]);
|
||||||
|
|
||||||
|
const pick = (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
const invalid = validateVideoFile(file);
|
||||||
|
if (invalid) {
|
||||||
|
toast.error(invalid);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void upload(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upload = async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const res = await uploadVideo(0, file);
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin", "videos"] });
|
||||||
|
toast.success(file.name.toLowerCase().endsWith(".webm") ? "WebM آپلود شد" : "MP4 آپلود شد");
|
||||||
|
setAttachKey(res.key);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "آپلود ویدیو ممکن نشد"));
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const del = async (key: string) => {
|
||||||
|
if (!(await confirm("حذف ویدیو", `فایل «${key}» از کتابخانه حذف شود؟`))) return;
|
||||||
|
try {
|
||||||
|
await http.del(`/api/admin/videos?key=${encodeURIComponent(key)}`);
|
||||||
|
await qc.invalidateQueries({ queryKey: ["admin", "videos"] });
|
||||||
|
toast.success("از کتابخانه حذف شد");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "حذف ویدیو ممکن نشد"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{dialog}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="border border-dashed border-primary/40 bg-primary/5">
|
||||||
|
<CardContent className="flex flex-col items-stretch gap-4 py-6 sm:flex-row sm:items-center sm:justify-between sm:px-8">
|
||||||
|
<div className="min-w-0 text-center sm:text-right">
|
||||||
|
<p className="text-lg font-black">آپلود ویدیو</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
فقط <span className="font-bold text-foreground">MP4</span> (پیشنهادی) یا{" "}
|
||||||
|
<span className="font-bold text-foreground">WebM</span> · حداکثر ۲۰۰ مگابایت
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">بعد از آپلود، درس مقصد را انتخاب کنید.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 justify-center">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".mp4,.webm,video/mp4,video/webm"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => pick(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="min-w-40 gap-2"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
{uploading ? "در حال آپلود…" : "انتخاب MP4 / WebM"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{(isError || lessonsError) && (
|
||||||
|
<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(isError ? error : lessonsErr, "بارگذاری کتابخانه ممکن نشد")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
void refetch();
|
||||||
|
void refetchLessons();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
تلاش دوباره
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col gap-3 space-y-0 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
کتابخانه ویدیو
|
||||||
|
{isFetching && !isLoading && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{videos.length} فایل · روی تصویر بزنید تا پخش شود
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<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>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-52 rounded-2xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{filtered.map((v) => {
|
||||||
|
const used = usedByKey.get(v.key) ?? [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.key}
|
||||||
|
className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-3"
|
||||||
|
>
|
||||||
|
<VideoThumb videoKey={v.key} onOpen={() => setPreviewKey(v.key)} />
|
||||||
|
<div className="min-w-0 px-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||||||
|
{formatLabel(v.key)}
|
||||||
|
</Badge>
|
||||||
|
{v.key === "sample.mp4" && (
|
||||||
|
<Badge variant="outline" className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
پیشفرض پلیر
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<p className="truncate text-sm font-extrabold" title={v.key} dir="ltr">
|
||||||
|
{v.key}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">{formatBytes(v.size)}</p>
|
||||||
|
{v.key === "sample.mp4" && (
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||||
|
اگر درسی ویدیو نداشته باشد، این فایل بهصورت خودکار در پلیر پخش میشود.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{used.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{used.map((l) => (
|
||||||
|
<Badge key={l.id} variant="secondary" className="text-[10px]">
|
||||||
|
L{l.id} · {l.title}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : v.key !== "sample.mp4" ? (
|
||||||
|
<p className="mt-2 text-[11px] font-bold text-warning">به هیچ درسی وصل نیست</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 px-1 pb-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={() => setPreviewKey(v.key)}
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" /> پخش
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 gap-1.5"
|
||||||
|
onClick={() => setAttachKey(v.key)}
|
||||||
|
disabled={lessons.length === 0}
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5" /> وصل به درس
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => void del(v.key)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{filtered.length === 0 && !isLoading && !isError && (
|
||||||
|
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
{q ? "نتیجهای نیست" : "هنوز ویدیویی آپلود نشده — یک MP4 انتخاب کنید"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<AttachVideoDialog
|
||||||
|
videoKey={attachKey}
|
||||||
|
lessons={lessons}
|
||||||
|
onClose={() => setAttachKey(null)}
|
||||||
|
onDone={() => {
|
||||||
|
setAttachKey(null);
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "lessons"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VideoPreviewDialog videoKey={previewKey} onClose={() => setPreviewKey(null)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttachVideoDialog({
|
||||||
|
videoKey,
|
||||||
|
lessons,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
videoKey: string | null;
|
||||||
|
lessons: Lesson[];
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(null);
|
||||||
|
}, [videoKey]);
|
||||||
|
|
||||||
|
const attach = async () => {
|
||||||
|
if (!videoKey || selected == null) return;
|
||||||
|
const lesson = lessons.find((l) => l.id === selected);
|
||||||
|
if (!lesson) {
|
||||||
|
toast.error("درس انتخابشده پیدا نشد.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await http.put(`/api/admin/lessons/${lesson.id}`, { ...lesson, videoKey });
|
||||||
|
toast.success(`ویدیو به «${lesson.title}» وصل شد`);
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e, "اتصال ویدیو به درس ممکن نشد"));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={!!videoKey}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) {
|
||||||
|
setSelected(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 sm:rounded-2xl">
|
||||||
|
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start">
|
||||||
|
<DialogTitle>وصل کردن ویدیو به درس</DialogTitle>
|
||||||
|
<DialogDescription className="truncate font-mono text-xs" dir="ltr">
|
||||||
|
{videoKey}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="max-h-72 space-y-2 overflow-y-auto px-4 py-3">
|
||||||
|
{lessons.length === 0 && (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
هنوز درسی نیست — اول از تب دروس یک درس بسازید.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lessons
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((l) => {
|
||||||
|
const active = selected === l.id;
|
||||||
|
const already = l.videoKey === videoKey;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(l.id)}
|
||||||
|
className={cn(
|
||||||
|
"grid w-full grid-cols-[2rem_minmax(0,1fr)_1rem] items-center gap-3 rounded-xl border px-3 py-3 text-right transition-colors",
|
||||||
|
active ? "border-primary bg-primary/10" : "border-border hover:border-primary/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="grid h-8 w-8 place-items-center rounded-xl bg-muted text-xs font-black">
|
||||||
|
L{l.id}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-sm font-extrabold">{l.title}</span>
|
||||||
|
<span className="block truncate text-[11px] text-muted-foreground">
|
||||||
|
{already ? "همین ویدیو وصل است" : l.videoKey ? `فعلی: ${l.videoKey}` : "بدون ویدیو"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{active ? <Check className="h-4 w-4 text-primary" /> : <span />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="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} disabled={busy}>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
<Button className="h-10 min-w-24" disabled={selected == null || busy} onClick={() => void attach()}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "وصل کن"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact picker for LessonDialog — browse library instead of typing the key. */
|
||||||
|
export function VideoKeyPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (key: string) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [previewKey, setPreviewKey] = useState<string | null>(null);
|
||||||
|
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||||
|
queryKey: ["admin", "videos"],
|
||||||
|
queryFn: () => http.get<{ videos: VideoItem[] }>("/api/admin/videos"),
|
||||||
|
enabled: open,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
const videos = data?.videos ?? [];
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase();
|
||||||
|
if (!term) return videos;
|
||||||
|
return videos.filter((v) => v.key.toLowerCase().includes(term));
|
||||||
|
}, [videos, q]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>ویدیو درس</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-11 flex-1 items-center gap-2 rounded-xl border px-3",
|
||||||
|
value ? "border-success/40 bg-success/5" : "border-dashed border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className={cn("truncate text-sm font-bold", !value && "text-muted-foreground")} dir="ltr">
|
||||||
|
{value || "ویدیویی انتخاب نشده"}
|
||||||
|
</span>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-auto shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => onChange("")}
|
||||||
|
aria-label="پاک کردن ویدیو"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" className="h-11 shrink-0 gap-1.5" onClick={() => setOpen(true)}>
|
||||||
|
<Search className="h-4 w-4" /> انتخاب
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreviewKey(value)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-bold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" /> پیشنمایش ویدیو انتخابشده
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0 sm:rounded-2xl">
|
||||||
|
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 text-start">
|
||||||
|
<DialogTitle>انتخاب از کتابخانه</DialogTitle>
|
||||||
|
<DialogDescription>فقط MP4 و WebMهای آپلودشده</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 px-4 py-3">
|
||||||
|
<div className="relative">
|
||||||
|
<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>
|
||||||
|
{isError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="flex-1">{toUserError(error)}</span>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7" onClick={() => void refetch()}>
|
||||||
|
دوباره
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-80 space-y-2 overflow-y-auto">
|
||||||
|
{isLoading && <Skeleton className="h-24 rounded-xl" />}
|
||||||
|
{filtered.map((v) => {
|
||||||
|
const active = v.key === value;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.key}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-[7rem_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border p-2",
|
||||||
|
active ? "border-primary bg-primary/10" : "border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<VideoThumb
|
||||||
|
videoKey={v.key}
|
||||||
|
className="rounded-lg"
|
||||||
|
onOpen={() => setPreviewKey(v.key)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 text-right"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(v.key);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="block truncate text-sm font-extrabold" dir="ltr">
|
||||||
|
{v.key}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatLabel(v.key)} · {formatBytes(v.size)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{active ? (
|
||||||
|
<Check className="h-4 w-4 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(v.key);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
انتخاب
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && !isLoading && !isError && (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
ویدیویی نیست — اول از تب ویدیوها یک MP4 آپلود کنید
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<VideoPreviewDialog videoKey={previewKey} onClose={() => setPreviewKey(null)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Loader2, Trash2, Upload } from "lucide-react";
|
||||||
|
import { http, toUserError } from "@/lib/api";
|
||||||
|
import type { RegistrationPhoneWhitelist, WhitelistStatus } 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 { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { toast } from "@/components/providers";
|
||||||
|
import { formatFaNumber } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
ListPagination,
|
||||||
|
buildPageQuery,
|
||||||
|
type Paginated,
|
||||||
|
} from "@/components/admin/list-pagination";
|
||||||
|
import { ListToolbar } from "@/components/admin/list-toolbar";
|
||||||
|
import { useConfirm } from "@/components/admin/use-confirm";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<WhitelistStatus, string> = {
|
||||||
|
available: "آزاد",
|
||||||
|
consumed: "مصرفشده",
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusFilter = "" | WhitelistStatus;
|
||||||
|
|
||||||
|
type ImportResult = {
|
||||||
|
inserted: number;
|
||||||
|
skipped: number;
|
||||||
|
invalid: number;
|
||||||
|
rejected?: number;
|
||||||
|
rejectedPhones?: string[];
|
||||||
|
blacklistedConflicts?: string[];
|
||||||
|
removedBlacklist?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WhitelistPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { confirm, dialog } = useConfirm();
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [filter, setFilter] = useState<StatusFilter>("");
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState<number | null>(null);
|
||||||
|
const [pendingConflicts, setPendingConflicts] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [filter, q]);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["admin", "phone-whitelist", filter, q, page],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams(buildPageQuery(page));
|
||||||
|
if (filter) params.set("status", filter);
|
||||||
|
if (q.trim()) params.set("q", q.trim());
|
||||||
|
return http.get<Paginated<RegistrationPhoneWhitelist>>(
|
||||||
|
`/api/admin/phone-whitelist?${params.toString()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = useMemo(() => data?.items ?? [], [data]);
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const pageSize = data?.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||||
|
const availableCount = useMemo(
|
||||||
|
() => entries.filter((e) => e.status === "available").length,
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const summarizeImport = (res: ImportResult) => {
|
||||||
|
const parts = [
|
||||||
|
`${formatFaNumber(res.inserted)} افزوده`,
|
||||||
|
res.skipped ? `${formatFaNumber(res.skipped)} تکراری` : null,
|
||||||
|
res.removedBlacklist
|
||||||
|
? `${formatFaNumber(res.removedBlacklist)} از فهرست سیاه حذف`
|
||||||
|
: null,
|
||||||
|
res.invalid ? `${formatFaNumber(res.invalid)} نامعتبر` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
if (parts.length > 0) {
|
||||||
|
toast.success(parts.join(" · "));
|
||||||
|
}
|
||||||
|
if ((res.rejected ?? 0) > 0) {
|
||||||
|
const phones = res.rejectedPhones ?? [];
|
||||||
|
const detail =
|
||||||
|
phones.length > 0
|
||||||
|
? ` (${phones.slice(0, 3).join("، ")}${phones.length > 3 ? "…" : ""})`
|
||||||
|
: "";
|
||||||
|
toast.error(
|
||||||
|
`${formatFaNumber(res.rejected ?? 0)} شماره قبلاً ثبتنام شده و قابل افزودن به فهرست مجاز نیست${detail}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const postImport = async (payload: {
|
||||||
|
text?: string;
|
||||||
|
phones?: string[];
|
||||||
|
resolveConflicts?: boolean;
|
||||||
|
}) => {
|
||||||
|
return http.post<ImportResult>("/api/admin/phone-whitelist/import", payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishImport = (res: ImportResult) => {
|
||||||
|
summarizeImport(res);
|
||||||
|
setText("");
|
||||||
|
setPendingConflicts([]);
|
||||||
|
setPage(1);
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "phone-whitelist"] });
|
||||||
|
if (res.removedBlacklist) {
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "phone-blacklist"] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importPhones = async () => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
toast.error("حداقل یک شماره وارد کنید");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setPendingConflicts([]);
|
||||||
|
try {
|
||||||
|
const res = await postImport({ text });
|
||||||
|
const conflicts = res.blacklistedConflicts ?? [];
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
summarizeImport(res);
|
||||||
|
if (conflicts.length === 1) {
|
||||||
|
const phone = conflicts[0];
|
||||||
|
const ok = await confirm(
|
||||||
|
"این شماره در فهرست سیاه است",
|
||||||
|
`شماره ${phone} در فهرست سیاه است. حذف از فهرست سیاه و افزودن به فهرست مجاز؟`,
|
||||||
|
"حذف از فهرست سیاه و افزودن",
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
setPendingConflicts(conflicts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = await postImport({ phones: conflicts, resolveConflicts: true });
|
||||||
|
finishImport(resolved);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingConflicts(conflicts);
|
||||||
|
toast.error(
|
||||||
|
`${formatFaNumber(conflicts.length)} شماره در فهرست سیاه است — برای ادامه تعارض را حل کنید`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishImport(res);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveConflicts = async () => {
|
||||||
|
if (pendingConflicts.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await postImport({ phones: pendingConflicts, resolveConflicts: true });
|
||||||
|
finishImport(res);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: number) => {
|
||||||
|
setDeleteBusy(id);
|
||||||
|
try {
|
||||||
|
await http.del(`/api/admin/phone-whitelist/${id}`);
|
||||||
|
toast.success("شماره از فهرست حذف شد");
|
||||||
|
qc.invalidateQueries({ queryKey: ["admin", "phone-whitelist"] });
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(toUserError(e));
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{dialog}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1 pb-3">
|
||||||
|
<CardTitle className="text-base">واردات شماره</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
هر شماره پس از یک ثبتنام موفق مصرف میشود. فرمت: هر خط یا جدا با ویرگول.
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wl-text">شمارهها</Label>
|
||||||
|
<Textarea
|
||||||
|
id="wl-text"
|
||||||
|
className="min-h-[120px] text-left tracking-wide"
|
||||||
|
dir="ltr"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder={"09121234567\n09129876543"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="gradient" disabled={busy} onClick={() => void importPhones()}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
واردات
|
||||||
|
</Button>
|
||||||
|
{pendingConflicts.length > 0 && (
|
||||||
|
<div className="space-y-3 rounded-xl border border-border bg-muted/30 p-4">
|
||||||
|
<p className="text-sm font-medium">این شماره در فهرست سیاه است</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
حذف از فهرست سیاه و افزودن به فهرست مجاز؟
|
||||||
|
</p>
|
||||||
|
<ul className="max-h-40 space-y-1 overflow-y-auto text-sm tracking-wide" dir="ltr">
|
||||||
|
{pendingConflicts.map((phone) => (
|
||||||
|
<li key={phone}>{phone}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="gradient"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void resolveConflicts()}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
حل تعارض و ادامه
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setPendingConflicts([])}
|
||||||
|
>
|
||||||
|
انصراف
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-1 pb-3">
|
||||||
|
<CardTitle className="text-base">فهرست مجاز</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">جستجو و فیلتر شمارههای ثبتشده.</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ListToolbar>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["", "همه"],
|
||||||
|
["available", "آزاد"],
|
||||||
|
["consumed", "مصرفشده"],
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<Button
|
||||||
|
key={value || "all"}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={filter === value ? "default" : "outline"}
|
||||||
|
onClick={() => setFilter(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="جستجو با شماره…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
className="max-w-xs text-left tracking-wide"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{(q || filter) && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setQ("");
|
||||||
|
setFilter("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
پاک کردن
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ListToolbar>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && entries.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
هنوز شمارهای در فهرست نیست.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isLoading && entries.length > 0 && filter === "" && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
آزاد در این صفحه: {formatFaNumber(availableCount)} از {formatFaNumber(entries.length)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium tracking-wide" dir="ltr">
|
||||||
|
{entry.phone}
|
||||||
|
</p>
|
||||||
|
<Badge variant={entry.status === "available" ? "secondary" : "outline"}>
|
||||||
|
{STATUS_LABEL[entry.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
افزوده: {new Date(entry.createdAt).toLocaleString("fa-IR")}
|
||||||
|
{entry.consumedAt
|
||||||
|
? ` · مصرف: ${new Date(entry.consumedAt).toLocaleString("fa-IR")}`
|
||||||
|
: null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{entry.status === "available" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteBusy === entry.id}
|
||||||
|
onClick={() => void remove(entry.id)}
|
||||||
|
>
|
||||||
|
{deleteBusy === entry.id ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
حذف
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ListPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user