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) ---------------------- */
|
/* ---------------------- AI pipelines health (3c) ---------------------- */
|
||||||
|
|
||||||
interface FailureRow {
|
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,
|
Ticket,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Landmark,
|
Landmark,
|
||||||
|
Bell,
|
||||||
Activity,
|
Activity,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
@ -35,6 +36,7 @@ const NAV: NavItem[] = [
|
|||||||
{ label: "تخفیفها", to: "/admin/discounts", icon: Ticket },
|
{ label: "تخفیفها", to: "/admin/discounts", icon: Ticket },
|
||||||
{ label: "مجموعهها", to: "/admin/collections", icon: LayoutGrid },
|
{ label: "مجموعهها", to: "/admin/collections", icon: LayoutGrid },
|
||||||
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
|
{ label: "دفتر مالی", to: "/admin/ledger", icon: Landmark },
|
||||||
|
{ label: "اعلانها", to: "/admin/notifications", icon: Bell },
|
||||||
{ label: "سلامت AI", to: "/admin/ai-health", icon: Activity },
|
{ label: "سلامت AI", to: "/admin/ai-health", icon: Activity },
|
||||||
{ label: "مدیریت محتوا", to: "/admin/moderation", icon: MessageSquareWarning },
|
{ label: "مدیریت محتوا", to: "/admin/moderation", icon: MessageSquareWarning },
|
||||||
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
|
{ label: "گزارش فعالیت", to: "/admin/audit", icon: ScrollText },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user