Yimaru-Admin/src/pages/notifications/NotificationsPage.tsx
Yared Yemane 035d73889e feat(admin): practice edit flow, bulk notifications, and composer UX
Add full practice edit via GET/PUT .../full endpoints with question reorder and collapsible cards. Integrate bulk and scheduled SMS, email, push, and in-app notifications with a scheduled jobs page and improved recipient picker search.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 05:26:35 -07:00

822 lines
33 KiB
TypeScript

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 (
<div
className={cn(
"group relative flex gap-4 rounded-xl border p-4 transition-all",
notification.is_read
? "border-transparent bg-white hover:bg-grayScale-50"
: "border-brand-100 bg-brand-50/30 hover:bg-brand-50/50",
)}
>
{/* Unread dot */}
{!notification.is_read && (
<span className="absolute left-1.5 top-1.5 h-2 w-2 rounded-full bg-brand-500" />
)}
{/* Icon */}
<div
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-xl",
config.bg,
config.color,
)}
>
<Icon className="h-5 w-5" />
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm font-semibold",
notification.is_read ? "text-grayScale-600" : "text-grayScale-800",
)}
>
{getNotificationTitle(notification)}
</span>
<Badge variant={getNotificationLevelBadge(notification.level)} className="text-[10px] px-1.5 py-0">
{notification.level}
</Badge>
</div>
<p
className={cn(
"mt-0.5 text-sm leading-relaxed",
notification.is_read ? "text-grayScale-400" : "text-grayScale-600",
)}
>
{getNotificationMessage(notification)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-grayScale-400">
{formatNotificationTimestamp(notification.timestamp)}
</span>
<button
type="button"
disabled={toggling}
onClick={() => onToggleRead(notification.id, notification.is_read)}
className={cn(
"grid h-7 w-7 place-items-center rounded-lg transition-colors",
"opacity-0 group-hover:opacity-100 focus:opacity-100",
notification.is_read
? "text-grayScale-400 hover:bg-brand-50 hover:text-brand-600"
: "text-brand-500 hover:bg-brand-100 hover:text-brand-700",
toggling && "opacity-50",
)}
title={notification.is_read ? "Mark as unread" : "Mark as read"}
>
{toggling ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : notification.is_read ? (
<Mail className="h-3.5 w-3.5" />
) : (
<MailOpen className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
{/* Meta row */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{formatNotificationTypeLabel(notification.type)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-2 py-0">
{notification.delivery_channel}
</Badge>
{notification.delivery_status !== "delivered" && notification.delivery_status !== "pending" && (
<Badge variant="warning" className="text-[10px] px-2 py-0">
{notification.delivery_status}
</Badge>
)}
{notification.payload.tags && notification.payload.tags.length > 0 && (
notification.payload.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-2 py-0">
{tag}
</Badge>
))
)}
</div>
</div>
</div>
)
}
export function NotificationsPage() {
const navigate = useNavigate()
const [notifications, setNotifications] = useState<Notification[]>([])
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<Set<string>>(new Set())
const [bulkLoading, setBulkLoading] = useState(false)
const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null)
const [selectedNotificationId, setSelectedNotificationId] = useState<string | null>(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 (
<div className="mx-auto w-full max-w-6xl bg-grayScale-50/60 rounded-2xl px-3 py-4 sm:px-4 sm:py-5">
{/* Header */}
<div className="mb-5">
<div className="mb-1 text-sm font-semibold text-grayScale-500">Notifications</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">My Notifications</h1>
{totalCount > 0 && <Badge variant="secondary">{totalCount}</Badge>}
{globalUnread > 0 && <Badge variant="default">{globalUnread} unread</Badge>}
</div>
{/* Bulk actions */}
{!loading && !error && (
<div className="flex items-center gap-2">
<Button
size="sm"
className="bg-brand-500 text-white hover:bg-brand-600"
onClick={() => navigate("/notifications/create")}
>
<Mail className="mr-2 h-3.5 w-3.5" />
Send notification
</Button>
{notifications.length > 0 && (
<>
{globalUnread > 0 ? (
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllRead}
>
{bulkLoading ? (
<SpinnerIcon className="mr-2 h-3.5 w-3.5" />
) : (
<CheckCheck className="mr-2 h-3.5 w-3.5" />
)}
Mark all read
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={bulkLoading}
onClick={handleMarkAllUnread}
>
{bulkLoading ? (
<SpinnerIcon className="mr-2 h-3.5 w-3.5" />
) : (
<MailX className="mr-2 h-3.5 w-3.5" />
)}
Mark all unread
</Button>
)}
</>
)}
</div>
)}
</div>
</div>
{/* Summary cards */}
{!loading && !error && (
<div className="mb-5 grid gap-4 sm:grid-cols-3">
<Card className="shadow-none border border-grayScale-100">
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-xs font-medium text-grayScale-500">Total notifications</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700">
{totalCount.toLocaleString()}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-brand-500/90 text-white">
<Bell className="h-5 w-5" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-grayScale-100">
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-xs font-medium text-grayScale-500">Unread</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700">
{globalUnread.toLocaleString()}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-amber-50 text-amber-600">
<BellOff className="h-5 w-5" />
</div>
</CardContent>
</Card>
<Card className="shadow-none border border-grayScale-100">
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-xs font-medium text-grayScale-500">Channels used</p>
<p className="mt-1 text-xl font-semibold text-grayScale-700">
{Array.from(new Set(notifications.map((n) => n.delivery_channel))).length || "—"}
</p>
</div>
<div className="grid h-10 w-10 place-items-center rounded-xl bg-grayScale-50 text-grayScale-500">
<MailOpen className="h-5 w-5" />
</div>
</CardContent>
</Card>
</div>
)}
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-20">
<SpinnerIcon className="h-6 w-6" />
</div>
)}
{/* Error */}
{!loading && error && (
<Card className="shadow-none">
<CardContent className="flex flex-col items-center gap-3 py-16">
<AlertTriangle className="h-8 w-8 text-destructive" />
<span className="text-sm text-destructive">Failed to load notifications.</span>
<Button variant="outline" size="sm" onClick={() => fetchData(offset)}>
Retry
</Button>
</CardContent>
</Card>
)}
{/* Empty */}
{!loading && !error && notifications.length === 0 && (
<Card className="shadow-none">
<CardContent className="flex flex-col items-center gap-3 py-20">
<div className="grid h-14 w-14 place-items-center rounded-2xl bg-grayScale-100">
<BellOff className="h-7 w-7 text-grayScale-400" />
</div>
<span className="text-sm font-medium text-grayScale-500">No notifications yet</span>
<span className="text-xs text-grayScale-400">
When you receive notifications, they'll appear here.
</span>
</CardContent>
</Card>
)}
{/* Filters + table */}
{!loading && !error && notifications.length > 0 && (
<>
{/* Status tabs */}
<div className="mb-2 border-b border-grayScale-200">
<div className="-mb-px flex gap-6">
{(["all", "unread", "read"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveStatusTab(tab)}
className={cn(
"relative px-1 pb-3.5 pt-1 text-sm font-semibold transition-all",
activeStatusTab === tab
? "text-brand-600"
: "text-grayScale-400 hover:text-grayScale-700",
)}
>
{tab === "all" ? "All" : tab === "unread" ? "Unread" : "Read"}
{activeStatusTab === tab && (
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-brand-500" />
)}
</button>
))}
</div>
</div>
{/* Filters */}
<Card className="mb-3 shadow-none">
<CardContent className="flex flex-wrap items-center gap-3 p-4">
<div className="relative flex-1 min-w-[180px] max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
<Input
placeholder="Search by title, message, or type…"
className="pl-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Channel</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-[130px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
>
<span className="truncate">{channelFilter === "all" ? "All" : channelFilter.toUpperCase()}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[130px]">
<DropdownMenuRadioGroup
value={channelFilter}
onValueChange={(value) => setChannelFilter(value as typeof channelFilter)}
>
<DropdownMenuRadioItem value="all">All</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="push">Push</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="sms">SMS</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Type</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-[150px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
>
<span className="truncate">
{typeFilter === "all" ? "All types" : formatNotificationTypeLabel(typeFilter)}
</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[220px]">
<DropdownMenuRadioGroup value={typeFilter} onValueChange={setTypeFilter}>
<DropdownMenuRadioItem value="all">All types</DropdownMenuRadioItem>
{Array.from(new Set(notifications.map((n) => n.type))).map((t) => (
<DropdownMenuRadioItem key={t} value={t}>
{formatNotificationTypeLabel(t)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-grayScale-500">Level</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-[130px] justify-between rounded-lg border-grayScale-200 px-2.5 text-xs font-normal text-grayScale-600"
>
<span className="truncate">{levelFilter === "all" ? "All levels" : levelFilter}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5 text-grayScale-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[150px]">
<DropdownMenuRadioGroup value={levelFilter} onValueChange={setLevelFilter}>
<DropdownMenuRadioItem value="all">All levels</DropdownMenuRadioItem>
{Array.from(new Set(notifications.map((n) => n.level))).map((lvl) => (
<DropdownMenuRadioItem key={lvl} value={lvl}>
{lvl}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
<Card className="overflow-hidden rounded-xl border bg-white shadow-none">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Title</TableHead>
<TableHead className="hidden lg:table-cell">Message</TableHead>
<TableHead>Channel</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden sm:table-cell">Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredNotifications.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center">
<div className="flex flex-col items-center gap-3">
<BellOff className="h-8 w-8 text-grayScale-200" />
<div>
<p className="text-sm font-medium text-grayScale-500">No notifications match your filters</p>
<p className="mt-1 text-xs text-grayScale-400">Try adjusting your filters</p>
</div>
</div>
</TableCell>
</TableRow>
) : (
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 (
<TableRow
key={n.id}
className={cn(
"cursor-pointer",
!n.is_read && "bg-brand-50/10 hover:bg-brand-50/25",
)}
onClick={() => handleOpenDetail(n)}
>
<TableCell>
<div className="flex items-center gap-2">
<div
className={cn(
"grid h-8 w-8 place-items-center rounded-lg text-xs",
config.bg,
config.color,
)}
>
<Icon className="h-4 w-4" />
</div>
<span className="text-xs font-medium text-grayScale-600">
{formatNotificationTypeLabel(n.type)}
</span>
</div>
</TableCell>
<TableCell>
<p
className={cn(
"max-w-xs truncate text-sm font-medium",
n.is_read ? "text-grayScale-600" : "text-grayScale-800",
)}
>
{getNotificationTitle(n)}
</p>
</TableCell>
<TableCell className="hidden lg:table-cell">
<p className="max-w-sm truncate text-xs text-grayScale-500">
{getNotificationMessage(n)}
</p>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px] capitalize">
{n.delivery_channel}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={getNotificationLevelBadge(n.level)}
className="text-[10px] uppercase tracking-wide"
>
{n.is_read ? "Read" : "Unread"}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
<span className="text-xs text-grayScale-400">
{formatNotificationTimestamp(n.timestamp)}
</span>
</TableCell>
<TableCell className="text-right">
<div
className="flex items-center justify-end gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={isToggling}
onClick={() => handleToggleRead(n.id, n.is_read)}
title={n.is_read ? "Mark as unread" : "Mark as read"}
>
{isToggling ? (
<SpinnerIcon className="h-3.5 w-3.5" />
) : n.is_read ? (
<Mail className="h-3.5 w-3.5 text-grayScale-400" />
) : (
<MailOpen className="h-3.5 w-3.5 text-brand-500" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => handleOpenDetail(n)}
>
View
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</CardContent>
<div className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3 text-sm text-grayScale-500">
<div className="flex items-center gap-2">
<span>Showing</span>
<span className="font-medium text-grayScale-600">
{startEntry}-{endEntry}
</span>
<span>of</span>
<span className="font-medium text-grayScale-600">{totalCount}</span>
<span className="mr-4">entries</span>
<span className="border-l pl-4">Rows per page</span>
<div className="relative">
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value))
setOffset(0)
}}
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
>
{TABLE_PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => currentPage > 1 && setOffset(Math.max(0, offset - pageSize))}
disabled={currentPage <= 1}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
currentPage <= 1 && "cursor-not-allowed opacity-50",
)}
>
<ChevronLeft className="h-4 w-4" />
</button>
{getPageNumbers().map((n, idx) =>
typeof n === "string" ? (
<span key={`ellipsis-${idx}`} className="px-2 text-grayScale-400">
...
</span>
) : (
<button
key={n}
type="button"
onClick={() => setOffset((n - 1) * pageSize)}
className={cn(
"h-8 w-8 rounded-md border text-sm font-medium",
n === currentPage
? "border-brand-500 bg-brand-500 text-white"
: "bg-white text-grayScale-600 hover:bg-grayScale-50",
)}
>
{n}
</button>
),
)}
<button
onClick={() => currentPage < totalPages && setOffset(offset + pageSize)}
disabled={currentPage >= totalPages}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-md border bg-white text-grayScale-500",
currentPage >= totalPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</Card>
</>
)}
<NotificationDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
notification={selectedNotification}
loading={detailLoading}
error={detailError}
onRetry={
selectedNotificationId
? () => void loadNotificationDetail(selectedNotificationId)
: undefined
}
/>
</div>
)
}