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:
ahp
2026-10-07 19:40:26 +03:30
parent 5b4936903a
commit 5fbf30a70b
21 changed files with 10326 additions and 0 deletions
@@ -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>
);
}