From b4faa9d180784d61aeec99d8606580f2e882eabc Mon Sep 17 00:00:00 2001 From: Arda Samadi Date: Sat, 25 Jul 2026 15:11:36 +0330 Subject: [PATCH] admin: Slice 3e notifications UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/requestHandler/use-admin-hooks.ts | 99 ++++++++ app/routes/admin.notifications.tsx | 321 ++++++++++++++++++++++++++ app/routes/admin.tsx | 2 + 3 files changed, 422 insertions(+) create mode 100644 app/routes/admin.notifications.tsx diff --git a/app/requestHandler/use-admin-hooks.ts b/app/requestHandler/use-admin-hooks.ts index 3591e37..e020caf 100644 --- a/app/requestHandler/use-admin-hooks.ts +++ b/app/requestHandler/use-admin-hooks.ts @@ -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("notifications/overview/", token as string), + }); +} + +export const useNotificationTypes = (params: AdminListParams) => + useAdminList("notifications/types", params); +export const useNotifications = (params: AdminListParams) => + useAdminList("notifications/sent", params); +export const useSmsBlacklist = (params: AdminListParams) => + useAdminList("notifications/blacklist", params); + +export function useNotificationTypeCreate() { + const token = useAuthToken(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: Record) => + 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 }) => + 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 { diff --git a/app/routes/admin.notifications.tsx b/app/routes/admin.notifications.tsx new file mode 100644 index 0000000..c854527 --- /dev/null +++ b/app/routes/admin.notifications.tsx @@ -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 ( +
+ اعلان‌ها +
+ {TABS.map((t) => ( + + ))} +
+ {tab === "types" && } + {tab === "sent" && } + {tab === "blacklist" && } +
+ ); +} + +/* ------------------------------- templates ------------------------------- */ +function TypesTab() { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [editing, setEditing] = useState(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[] = [ + { + header: "عنوان", + render: (r) => ( +
+

{r.title}

+

{r.code}

+
+ ), + }, + { + header: "کانال‌ها", + render: (r) => ( +
+ {r.defaultInApp && } + {r.defaultSms && } +
+ ), + }, + { + header: "قالب", + render: (r) => ( + {r.template || "—"} + ), + }, + { + header: "", + render: (r) => ( +
+ + +
+ ), + }, + ]; + + return ( +
+
+ +
+ + { setSearch(v); setPage(1); }} placeholder="جستجوی کد / عنوان…" /> + + String(r.id)} emptyText="قالبی ثبت نشده است" /> + + {(creating || editing) && ( + { setCreating(false); setEditing(null); }} /> + )} +
+ ); +} + +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 ( +
+
e.stopPropagation()}> +
+

{row ? "ویرایش قالب" : "قالب جدید"}

+ +
+
+
+ + setCode(e.target.value)} dir="ltr" /> +
+
+ + setTitle(e.target.value)} /> +
+
+ + +
+
+ +