Vitron-Front/app/routes/admin.notifications.tsx
Arda Samadi b4faa9d180 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>
2026-07-25 15:11:36 +03:30

322 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";