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:
parent
97948767a0
commit
b4faa9d180
@ -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 {
|
||||
|
||||
321
app/routes/admin.notifications.tsx
Normal file
321
app/routes/admin.notifications.tsx
Normal 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";
|
||||
@ -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 },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user