import { useEffect, useState, useCallback } from "react" import { Bell, BellOff, AlertTriangle, ChevronLeft, ChevronRight, MailOpen, Mail, CheckCheck, MailX, Search, ChevronDown, } from "lucide-react" import { Card, CardContent } from "../../components/ui/card" import { Badge } from "../../components/ui/badge" import { Button } from "../../components/ui/button" import { Input } from "../../components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table" import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from "../../components/ui/dropdown-menu" import { cn } from "../../lib/utils" import { SpinnerIcon } from "../../components/ui/spinner-icon" import { useNavigate } from "react-router-dom" import { getNotificationById, getNotifications, getUnreadCount, markAsRead, markAsUnread, markAllRead, markAllUnread, } from "../../api/notifications.api" import { NotificationDetailDialog } from "../../components/notifications/NotificationDetailDialog" import { DEFAULT_NOTIFICATION_TYPE_CONFIG, formatNotificationTimestamp, formatNotificationTypeLabel, getNotificationLevelBadge, NOTIFICATION_TYPE_CONFIG, } from "../../lib/notificationDisplay" import { getNotificationMessage, getNotificationTitle, type Notification } from "../../types/notification.types" import { toast } from "sonner" import { DEFAULT_TABLE_PAGE_SIZE, TABLE_PAGE_SIZE_OPTIONS } from "../../lib/tablePagination" function NotificationItem({ notification, onToggleRead, toggling, }: { notification: Notification onToggleRead: (id: string, currentlyRead: boolean) => void toggling: boolean }) { const config = NOTIFICATION_TYPE_CONFIG[notification.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG const Icon = config.icon return (
{/* Unread dot */} {!notification.is_read && ( )} {/* Icon */}
{/* Content */}
{getNotificationTitle(notification)} {notification.level}

{getNotificationMessage(notification)}

{formatNotificationTimestamp(notification.timestamp)}
{/* Meta row */}
{formatNotificationTypeLabel(notification.type)} {notification.delivery_channel} {notification.delivery_status !== "delivered" && notification.delivery_status !== "pending" && ( {notification.delivery_status} )} {notification.payload.tags && notification.payload.tags.length > 0 && ( notification.payload.tags.map((tag) => ( {tag} )) )}
) } export function NotificationsPage() { const navigate = useNavigate() const [notifications, setNotifications] = useState([]) const [totalCount, setTotalCount] = useState(0) const [globalUnread, setGlobalUnread] = useState(0) const [offset, setOffset] = useState(0) const [pageSize, setPageSize] = useState(DEFAULT_TABLE_PAGE_SIZE) const [loading, setLoading] = useState(true) const [error, setError] = useState(false) const [togglingIds, setTogglingIds] = useState>(new Set()) const [bulkLoading, setBulkLoading] = useState(false) const [selectedNotification, setSelectedNotification] = useState(null) const [selectedNotificationId, setSelectedNotificationId] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const [detailLoading, setDetailLoading] = useState(false) const [detailError, setDetailError] = useState(false) const [channelFilter, setChannelFilter] = useState<"all" | "push" | "sms">("all") const [activeStatusTab, setActiveStatusTab] = useState<"all" | "read" | "unread">("all") const [searchTerm, setSearchTerm] = useState("") const [typeFilter, setTypeFilter] = useState<"all" | string>("all") const [levelFilter, setLevelFilter] = useState<"all" | string>("all") const fetchData = useCallback(async (currentOffset: number) => { setLoading(true) setError(false) try { const [notifRes, unreadRes] = await Promise.all([ getNotifications(pageSize, currentOffset), getUnreadCount(), ]) setNotifications(notifRes.data.notifications ?? []) setTotalCount(notifRes.data.total_count) setGlobalUnread(unreadRes.data.unread) } catch { setError(true) } finally { setLoading(false) } }, [pageSize]) useEffect(() => { fetchData(offset) }, [offset, pageSize, fetchData]) const handleToggleRead = useCallback(async (id: string, currentlyRead: boolean) => { setTogglingIds((prev) => new Set(prev).add(id)) try { if (currentlyRead) { await markAsUnread(id) } else { await markAsRead(id) } setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: !currentlyRead } : n)), ) setGlobalUnread((prev) => (currentlyRead ? prev + 1 : Math.max(0, prev - 1))) } catch { // silently fail — user can retry } finally { setTogglingIds((prev) => { const next = new Set(prev) next.delete(id) return next }) } }, []) const handleMarkAllRead = useCallback(async () => { setBulkLoading(true) try { await markAllRead() setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true }))) setGlobalUnread(0) } catch { // silently fail } finally { setBulkLoading(false) } }, []) const handleMarkAllUnread = useCallback(async () => { setBulkLoading(true) try { await markAllUnread() setNotifications((prev) => prev.map((n) => ({ ...n, is_read: false }))) setGlobalUnread(totalCount) } catch { // silently fail } finally { setBulkLoading(false) } }, [totalCount]) const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)) const currentPage = Math.floor(offset / pageSize) + 1 const startEntry = totalCount === 0 ? 0 : offset + 1 const endEntry = Math.min(offset + pageSize, totalCount) const getPageNumbers = () => { const pages: (number | string)[] = [] if (totalPages <= 7) { for (let i = 1; i <= totalPages; i++) pages.push(i) } else { pages.push(1, 2, 3) if (currentPage > 4) pages.push("...") if (currentPage > 3 && currentPage < totalPages - 2) pages.push(currentPage) if (currentPage < totalPages - 3) pages.push("...") pages.push(totalPages) } return pages } const filteredNotifications = notifications.filter((n) => { if (channelFilter !== "all" && n.delivery_channel !== channelFilter) return false if (activeStatusTab === "read" && !n.is_read) return false if (activeStatusTab === "unread" && n.is_read) return false if (typeFilter !== "all" && n.type !== typeFilter) return false if (levelFilter !== "all" && n.level !== levelFilter) return false if (searchTerm.trim()) { const q = searchTerm.toLowerCase() const haystack = [ getNotificationTitle(n), getNotificationMessage(n), formatNotificationTypeLabel(n.type), n.delivery_channel, n.level, ] .filter(Boolean) .join(" ") .toLowerCase() if (!haystack.includes(q)) return false } return true }) const loadNotificationDetail = useCallback(async (id: string) => { setDetailLoading(true) setDetailError(false) setSelectedNotification(null) setSelectedNotificationId(id) setDetailOpen(true) try { const res = await getNotificationById(id) if (!res.data) { setDetailError(true) toast.error("Notification not found") return } setSelectedNotification(res.data) if (!res.data.is_read) { setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)), ) setGlobalUnread((prev) => Math.max(0, prev - 1)) try { await markAsRead(id) } catch { // list refresh on next load will reconcile } } } catch { setDetailError(true) toast.error("Failed to load notification details") } finally { setDetailLoading(false) } }, []) const handleOpenDetail = (notification: Notification) => { void loadNotificationDetail(notification.id) } return (
{/* Header */}
Notifications

My Notifications

{totalCount > 0 && {totalCount}} {globalUnread > 0 && {globalUnread} unread}
{/* Bulk actions */} {!loading && !error && (
{notifications.length > 0 && ( <> {globalUnread > 0 ? ( ) : ( )} )}
)}
{/* Summary cards */} {!loading && !error && (

Total notifications

{totalCount.toLocaleString()}

Unread

{globalUnread.toLocaleString()}

Channels used

{Array.from(new Set(notifications.map((n) => n.delivery_channel))).length || "—"}

)} {/* Loading */} {loading && (
)} {/* Error */} {!loading && error && ( Failed to load notifications. )} {/* Empty */} {!loading && !error && notifications.length === 0 && (
No notifications yet When you receive notifications, they'll appear here.
)} {/* Filters + table */} {!loading && !error && notifications.length > 0 && ( <> {/* Status tabs */}
{(["all", "unread", "read"] as const).map((tab) => ( ))}
{/* Filters */}
setSearchTerm(e.target.value)} />
Channel setChannelFilter(value as typeof channelFilter)} > All Push SMS
Type All types {Array.from(new Set(notifications.map((n) => n.type))).map((t) => ( {formatNotificationTypeLabel(t)} ))}
Level All levels {Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => ( {lvl} ))}
Type Title Message Channel Status Created Actions {filteredNotifications.length === 0 ? (

No notifications match your filters

Try adjusting your filters

) : ( filteredNotifications.map((n) => { const config = NOTIFICATION_TYPE_CONFIG[n.type] ?? DEFAULT_NOTIFICATION_TYPE_CONFIG const Icon = config.icon const isToggling = togglingIds.has(n.id) return ( handleOpenDetail(n)} >
{formatNotificationTypeLabel(n.type)}

{getNotificationTitle(n)}

{getNotificationMessage(n)}

{n.delivery_channel} {n.is_read ? "Read" : "Unread"} {formatNotificationTimestamp(n.timestamp)}
e.stopPropagation()} >
) }) )}
Showing {startEntry}-{endEntry} of {totalCount} entries Rows per page
{getPageNumbers().map((n, idx) => typeof n === "string" ? ( ... ) : ( ), )}
)} void loadNotificationDetail(selectedNotificationId) : undefined } />
) }