siakad-itm/resources/js/components/notification-bell.tsx
Yoga Pangestu 0397959f60
Some checks failed
tests / ci (pull_request) Has been cancelled
feat: implement notification system with CRUD operations and UI integration
2026-08-25 13:21:11 +07:00

208 lines
10 KiB
TypeScript

import { Combobox as ComboboxPrimitive } from '@base-ui/react';
import { router, usePage, WhenVisible } from '@inertiajs/react';
import { format } from 'date-fns';
import { Bell, CheckCheck, Loader2, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { Button } from '@/components/ui/button';
import { Combobox, ComboboxContent } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import { destroy, markAllRead, clearAll, read } from '@/routes/notifications';
import type { Notification, NotificationPage } from '@/types/notification';
export function NotificationBell() {
const { props } = usePage<{ notifications?: NotificationPage }>();
const unreadCount = props.unreadNotificationsCount ?? 0;
const notifications = props.notifications ?? {
data: [],
current_page: 1,
last_page: 1,
per_page: 15,
total: 0,
};
const [open, setOpen] = useState(false);
const [clearAllOpen, setClearAllOpen] = useState(false);
const hasMore = notifications.current_page < notifications.last_page;
const hasNotifications = notifications.data.length > 0;
const hasUnread = notifications.data.some((n) => !n.is_read);
function handleMarkAllRead() {
router.patch(markAllRead().url, {}, { preserveScroll: true });
}
function handleClearAll() {
router.delete(clearAll().url, {
preserveScroll: true,
onSuccess: () => setClearAllOpen(false),
});
}
function handleMarkRead(notification: Notification) {
router.patch(read(notification.id).url, {}, { preserveScroll: true });
}
function handleDelete(notification: Notification) {
router.delete(destroy(notification.id).url, { preserveScroll: true });
}
return (
<>
<Combobox open={open} onOpenChange={setOpen}>
<ComboboxPrimitive.Trigger
render={
<Button
variant="ghost"
size="icon"
className="relative"
/>
}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Notifikasi</span>
</ComboboxPrimitive.Trigger>
<ComboboxContent
align="end"
className="w-96 max-w-[90vw] min-w-96 p-0"
>
<div className="flex items-center justify-between border-b p-3">
<span className="font-medium">Notifikasi</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
title="Tandai semua dibaca"
disabled={!hasUnread}
onClick={handleMarkAllRead}
>
<CheckCheck className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Hapus semua"
disabled={!hasNotifications}
onClick={() => setClearAllOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{hasNotifications ? (
<div className="flex flex-col divide-y">
{notifications.data.map((notification) => (
<div
key={notification.id}
className={cn(
'flex items-start justify-between gap-2 p-3',
!notification.is_read &&
'bg-primary/5',
)}
>
<div className="flex items-start gap-2">
{!notification.is_read && (
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />
)}
<div>
<p className="text-sm font-medium">
{notification.title ?? '-'}
</p>
{notification.content && (
<p className="mt-0.5 text-xs text-muted-foreground">
{notification.content}
</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
{format(
new Date(
notification.created_at,
),
'd MMM yyyy, HH:mm',
)}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{!notification.is_read && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Tandai dibaca"
onClick={() =>
handleMarkRead(
notification,
)
}
>
<CheckCheck className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Hapus"
onClick={() =>
handleDelete(notification)
}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
))}
{hasMore && (
<WhenVisible
always
data="notifications"
params={{
data: {
page:
notifications.current_page +
1,
},
}}
fallback={
<div className="flex justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
}
>
<div className="flex justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
</WhenVisible>
)}
</div>
) : (
<div className="flex h-24 items-center justify-center text-sm text-muted-foreground">
Belum ada notifikasi.
</div>
)}
</div>
</ComboboxContent>
</Combobox>
<ConfirmDialog
open={clearAllOpen}
onOpenChange={setClearAllOpen}
title="Hapus Semua Notifikasi"
description="Apakah Anda yakin ingin menghapus semua notifikasi? Tindakan ini tidak dapat dibatalkan."
confirmLabel="Hapus"
onConfirm={handleClearAll}
/>
</>
);
}