admin: Slice 3e notifications UI

- hooks: notification types (CRUD), sent list, overview, sms blacklist
  (list + delete); NotificationType/Notification/SmsBlacklist row types.
- /admin/notifications: tabbed — قالب‌ها (template CRUD modal), ارسال‌ها
  (overview KPIs + sent list), لیست سیاه (unblock).
- sidebar "اعلان‌ها" nav link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arda Samadi 2026-07-25 15:11:36 +03:30
parent 97948767a0
commit b4faa9d180
3 changed files with 422 additions and 0 deletions

View File

@ -418,6 +418,105 @@ export function useAdminCommentDelete() {
});
}
/* -------------------------- notifications (3e) -------------------------- */
export interface NotificationTypeRow {
id: number;
code: string;
title: string;
defaultSms: boolean;
defaultInApp: boolean;
template: string;
variables: string[];
}
export interface NotificationRow {
id: number;
typeCode: string | null;
typeTitle: string | null;
receiverPhone: string | null;
title: string;
body: string;
read: boolean;
sentSms: boolean;
sentInApp: boolean;
smsLineNumber: number | null;
createdAt: string;
}
export interface SmsBlacklistRow {
id: number;
mobile: string;
lineNumber: number;
reason: string | null;
createdAt: string;
}
export interface NotificationsOverview {
total: number;
smsSent: number;
inAppSent: number;
unread: number;
types: number;
pushSubscriptions: number;
blacklistedLines: number;
byType: { code: string; title: string; count: number }[];
}
export function useNotificationsOverview() {
const token = useAuthToken();
return useQuery({
queryKey: ["admin", "notifications", "overview"],
enabled: !!token,
queryFn: () => adminGet<NotificationsOverview>("notifications/overview/", token as string),
});
}
export const useNotificationTypes = (params: AdminListParams) =>
useAdminList<NotificationTypeRow>("notifications/types", params);
export const useNotifications = (params: AdminListParams) =>
useAdminList<NotificationRow>("notifications/sent", params);
export const useSmsBlacklist = (params: AdminListParams) =>
useAdminList<SmsBlacklistRow>("notifications/blacklist", params);
export function useNotificationTypeCreate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
adminPost("notifications/types/", data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/types"] }),
});
}
export function useNotificationTypeUpdate() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
adminPatch(`notifications/types/${id}/`, data, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/types"] }),
});
}
export function useNotificationTypeDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => adminDelete(`notifications/types/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/types"] }),
});
}
export function useSmsBlacklistDelete() {
const token = useAuthToken();
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => adminDelete(`notifications/blacklist/${id}/`, token as string),
onSuccess: () => qc.invalidateQueries({ queryKey: ["admin", "notifications/blacklist"] }),
});
}
/* ---------------------- AI pipelines health (3c) ---------------------- */
interface FailureRow {

View File

@ -0,0 +1,321 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, X, Ban } from "lucide-react";
import {
useNotificationsOverview,
useNotificationTypes,
useNotifications,
useSmsBlacklist,
useNotificationTypeCreate,
useNotificationTypeUpdate,
useNotificationTypeDelete,
useSmsBlacklistDelete,
type NotificationTypeRow,
type NotificationRow,
type SmsBlacklistRow,
} from "~/requestHandler/use-admin-hooks";
import {
AdminTable,
AdminPagination,
AdminToolbar,
AdminSearch,
AdminSelect,
AdminPageTitle,
StatusBadge,
faNum,
faDate,
useDebouncedValue,
type Column,
} from "~/components/admin/DataTable";
import { KpiCard } from "~/components/admin/KpiCard";
import { cn } from "~/lib/utils";
const PAGE_SIZE = 25;
export default function AdminNotifications() {
const [tab, setTab] = useState<"types" | "sent" | "blacklist">("types");
const TABS = [
{ k: "types", label: "قالب‌ها" },
{ k: "sent", label: "ارسال‌ها" },
{ k: "blacklist", label: "لیست سیاه" },
] as const;
return (
<div>
<AdminPageTitle>اعلانها</AdminPageTitle>
<div className="flex gap-1 mb-5 border-b border-WHITE3">
{TABS.map((t) => (
<button
key={t.k}
onClick={() => setTab(t.k)}
className={cn(
"px-4 py-2 text-[13.5px] font-semibold border-b-2 -mb-px transition-colors",
tab === t.k ? "border-BLACK text-BLACK" : "border-transparent text-GRAY hover:text-BLACK2"
)}
>
{t.label}
</button>
))}
</div>
{tab === "types" && <TypesTab />}
{tab === "sent" && <SentTab />}
{tab === "blacklist" && <BlacklistTab />}
</div>
);
}
/* ------------------------------- templates ------------------------------- */
function TypesTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [editing, setEditing] = useState<NotificationTypeRow | null>(null);
const [creating, setCreating] = useState(false);
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useNotificationTypes({ page, page_size: PAGE_SIZE, search: debouncedSearch });
const del = useNotificationTypeDelete();
const columns: Column<NotificationTypeRow>[] = [
{
header: "عنوان",
render: (r) => (
<div>
<p className="font-semibold">{r.title}</p>
<p className="text-GRAY text-[11px] tabular-nums" dir="ltr">{r.code}</p>
</div>
),
},
{
header: "کانال‌ها",
render: (r) => (
<div className="flex gap-1">
{r.defaultInApp && <StatusBadge label="این‌اپ" tone="blue" />}
{r.defaultSms && <StatusBadge label="پیامک" tone="green" />}
</div>
),
},
{
header: "قالب",
render: (r) => (
<span className="text-GRAY text-[11px] line-clamp-1 max-w-[220px]">{r.template || "—"}</span>
),
},
{
header: "",
render: (r) => (
<div className="flex items-center gap-1 justify-end">
<button onClick={() => setEditing(r)} className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 hover:bg-WHITE2" aria-label="ویرایش">
<Pencil size={14} />
</button>
<button
onClick={() => confirm(`حذف قالب «${r.title}»؟`) && del.mutate(r.id)}
className="grid h-8 w-8 place-items-center rounded-lg border border-WHITE3 text-RED hover:bg-RED/5"
aria-label="حذف"
>
<Trash2 size={14} />
</button>
</div>
),
},
];
return (
<div>
<div className="flex justify-end mb-3">
<button
onClick={() => setCreating(true)}
className="flex items-center gap-1.5 rounded-xl bg-BLACK text-white px-3.5 py-2 text-[13px] font-semibold hover:opacity-90"
>
<Plus size={16} /> قالب جدید
</button>
</div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => { setSearch(v); setPage(1); }} placeholder="جستجوی کد / عنوان…" />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="قالبی ثبت نشده است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
{(creating || editing) && (
<TypeModal row={editing} onClose={() => { setCreating(false); setEditing(null); }} />
)}
</div>
);
}
function TypeModal({ row, onClose }: { row: NotificationTypeRow | null; onClose: () => void }) {
const [code, setCode] = useState(row?.code ?? "");
const [title, setTitle] = useState(row?.title ?? "");
const [defaultSms, setDefaultSms] = useState(row?.defaultSms ?? false);
const [defaultInApp, setDefaultInApp] = useState(row?.defaultInApp ?? true);
const [template, setTemplate] = useState(row?.template ?? "");
const [variables, setVariables] = useState((row?.variables ?? []).join(", "));
const [error, setError] = useState("");
const create = useNotificationTypeCreate();
const update = useNotificationTypeUpdate();
const busy = create.isPending || update.isPending;
const submit = () => {
setError("");
if (!code || !title) return setError("کد و عنوان الزامی است.");
const payload = {
code,
title,
defaultSms,
defaultInApp,
template,
variables: variables.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean),
};
const onErr = (e: unknown) => setError(e instanceof Error ? e.message : "خطا در ذخیره");
if (row) update.mutate({ id: row.id, data: payload }, { onSuccess: onClose, onError: onErr });
else create.mutate(payload, { onSuccess: onClose, onError: onErr });
};
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-BLACK/40 sm:p-4" onClick={onClose}>
<div className="bg-WHITE w-full sm:max-w-lg rounded-t-2xl sm:rounded-2xl max-h-[92vh] overflow-y-auto hide-scrollbar" onClick={(e) => e.stopPropagation()}>
<div className="sticky top-0 bg-WHITE flex items-center justify-between px-5 py-4 border-b border-WHITE3">
<h3 className="font-extrabold text-[16px]">{row ? "ویرایش قالب" : "قالب جدید"}</h3>
<button onClick={onClose} className="grid h-8 w-8 place-items-center rounded-lg hover:bg-WHITE2"><X size={18} /></button>
</div>
<div className="p-5 flex flex-col gap-3">
<div>
<label className={labelCls}>کد (یکتا)</label>
<input className={inputCls} value={code} onChange={(e) => setCode(e.target.value)} dir="ltr" />
</div>
<div>
<label className={labelCls}>عنوان</label>
<input className={inputCls} value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={defaultInApp} onChange={(e) => setDefaultInApp(e.target.checked)} className="h-4 w-4" />
<span className="text-[13px] font-semibold">ایناپ پیشفرض</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={defaultSms} onChange={(e) => setDefaultSms(e.target.checked)} className="h-4 w-4" />
<span className="text-[13px] font-semibold">پیامک پیشفرض</span>
</label>
</div>
<div>
<label className={labelCls}>قالب پیام</label>
<textarea className={cn(inputCls, "min-h-[90px] resize-y")} value={template} onChange={(e) => setTemplate(e.target.value)} />
</div>
<div>
<label className={labelCls}>متغیرها (با کاما جدا شوند)</label>
<input className={inputCls} value={variables} onChange={(e) => setVariables(e.target.value)} dir="ltr" placeholder="seller.username, order.id" />
</div>
{error && <p className="text-RED text-[12px]">{error}</p>}
</div>
<div className="sticky bottom-0 bg-WHITE flex gap-2 px-5 py-4 border-t border-WHITE3">
<button onClick={onClose} className="flex-1 rounded-xl border border-WHITE3 py-2.5 text-[13.5px] font-semibold hover:bg-WHITE2">انصراف</button>
<button onClick={submit} disabled={busy} className="flex-1 rounded-xl bg-BLACK text-white py-2.5 text-[13.5px] font-semibold hover:opacity-90 disabled:opacity-50">
{busy ? "در حال ذخیره…" : "ذخیره"}
</button>
</div>
</div>
</div>
);
}
/* --------------------------------- sent --------------------------------- */
function SentTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [read, setRead] = useState("");
const [sentSms, setSentSms] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data: ov } = useNotificationsOverview();
const { data, isLoading } = useNotifications({
page,
page_size: PAGE_SIZE,
search: debouncedSearch,
read,
sent_sms: sentSms,
});
const reset = (fn: () => void) => { fn(); setPage(1); };
const columns: Column<NotificationRow>[] = [
{
header: "عنوان",
render: (r) => (
<div className="max-w-[280px]">
<p className="font-semibold text-[13px] truncate">{r.title}</p>
<p className="text-GRAY text-[11px]">{r.typeTitle || r.typeCode || "—"}</p>
</div>
),
},
{ header: "گیرنده", render: (r) => <span className="text-GRAY text-[11px] tabular-nums" dir="ltr">{r.receiverPhone || "—"}</span> },
{
header: "کانال",
render: (r) => (
<div className="flex gap-1">
{r.sentInApp && <StatusBadge label="این‌اپ" tone="blue" />}
{r.sentSms && <StatusBadge label="پیامک" tone="green" />}
{!r.sentInApp && !r.sentSms && <span className="text-GRAY text-[11px]"></span>}
</div>
),
},
{ header: "خوانده", render: (r) => (r.read ? <StatusBadge label="بله" tone="gray" /> : <StatusBadge label="خیر" tone="yellow" />) },
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
];
return (
<div>
{ov && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<KpiCard label="کل اعلان‌ها" value={faNum(ov.total)} />
<KpiCard label="پیامک ارسال‌شده" value={faNum(ov.smsSent)} accent="green" />
<KpiCard label="خوانده‌نشده" value={faNum(ov.unread)} accent={ov.unread > 0 ? "red" : "default"} />
<KpiCard label="اشتراک پوش" value={faNum(ov.pushSubscriptions)} />
</div>
)}
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => reset(() => setSearch(v))} placeholder="جستجوی گیرنده / عنوان…" />
<AdminSelect value={read} onChange={(v) => reset(() => setRead(v))} options={[{ value: "", label: "همه" }, { value: "false", label: "خوانده‌نشده" }, { value: "true", label: "خوانده‌شده" }]} />
<AdminSelect value={sentSms} onChange={(v) => reset(() => setSentSms(v))} options={[{ value: "", label: "همه" }, { value: "true", label: "با پیامک" }, { value: "false", label: "بدون پیامک" }]} />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="اعلانی یافت نشد" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
/* ------------------------------- blacklist ------------------------------- */
function BlacklistTab() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search);
const { data, isLoading } = useSmsBlacklist({ page, page_size: PAGE_SIZE, search: debouncedSearch });
const del = useSmsBlacklistDelete();
const columns: Column<SmsBlacklistRow>[] = [
{ header: "موبایل", render: (r) => <span className="tabular-nums" dir="ltr">{r.mobile}</span> },
{ header: "خط", render: (r) => <span className="tabular-nums" dir="ltr">{faNum(r.lineNumber)}</span> },
{ header: "دلیل", render: (r) => <span className="text-GRAY text-[11px] line-clamp-1 max-w-[220px]">{r.reason || "—"}</span> },
{ header: "تاریخ", render: (r) => <span className="text-GRAY whitespace-nowrap">{faDate(r.createdAt)}</span> },
{
header: "",
render: (r) => (
<button
onClick={() => confirm("حذف از لیست سیاه (رفع مسدودی)؟") && del.mutate(r.id)}
className="flex items-center gap-1 rounded-lg border border-WHITE3 px-2.5 h-8 text-[12px] hover:bg-WHITE2"
>
<Ban size={13} /> رفع مسدودی
</button>
),
},
];
return (
<div>
<AdminToolbar>
<AdminSearch value={search} onChange={(v) => { setSearch(v); setPage(1); }} placeholder="جستجوی موبایل…" />
</AdminToolbar>
<AdminTable columns={columns} rows={data?.results || []} isLoading={isLoading} getRowKey={(r) => String(r.id)} emptyText="لیست سیاه خالی است" />
<AdminPagination page={page} pageSize={PAGE_SIZE} count={data?.count || 0} onPageChange={setPage} />
</div>
);
}
const inputCls = "w-full rounded-xl border border-WHITE3 bg-WHITE px-3 py-2 text-[13.5px] focus:outline-none focus:border-GRAY";
const labelCls = "block text-[12px] text-GRAY mb-1";

View File

@ -8,6 +8,7 @@ import {
Ticket,
LayoutGrid,
Landmark,
Bell,
Activity,
MessageSquareWarning,
ScrollText,
@ -35,6 +36,7 @@ const NAV: NavItem[] = [
{ label: "تخفیف‌ها", to: "/admin/discounts", icon: Ticket },
{ label: "مجموعه‌ها", to: "/admin/collections", icon: LayoutGrid },
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
{ label: "اعلان‌ها", to: "/admin/notifications", icon: Bell },
{ label: "سلامت AI", to: "/admin/ai-health", icon: Activity },
{ label: "مدیریت محتوا", to: "/admin/moderation", icon: MessageSquareWarning },
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },