262 lines
12 KiB
TypeScript
262 lines
12 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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
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 [detail, setDetail] = useState<Notification | null>(null);
|
|
|
|
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 });
|
|
}
|
|
|
|
function handleOpenDetail(notification: Notification) {
|
|
setDetail(notification);
|
|
|
|
if (!notification.is_read) {
|
|
handleMarkRead(notification);
|
|
}
|
|
}
|
|
|
|
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="flex w-96 max-w-[90vw] min-w-96 flex-col p-0"
|
|
>
|
|
<div className="flex shrink-0 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="min-h-0 flex-1 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',
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="flex flex-1 cursor-pointer items-start gap-2 text-left"
|
|
onClick={() =>
|
|
handleOpenDetail(notification)
|
|
}
|
|
>
|
|
{!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 line-clamp-2 text-xs text-muted-foreground">
|
|
{notification.content}
|
|
</p>
|
|
)}
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{notification.creator
|
|
?.profile?.full_name &&
|
|
`${notification.creator.profile.full_name} · `}
|
|
{format(
|
|
new Date(
|
|
notification.created_at,
|
|
),
|
|
'd MMM yyyy, HH:mm',
|
|
)}
|
|
</p>
|
|
</div>
|
|
</button>
|
|
<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}
|
|
/>
|
|
|
|
<Dialog
|
|
open={detail !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDetail(null);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle>{detail?.title ?? '-'}</DialogTitle>
|
|
<DialogDescription>
|
|
{detail?.creator?.profile?.full_name &&
|
|
`${detail.creator.profile.full_name} · `}
|
|
{detail &&
|
|
format(
|
|
new Date(detail.created_at),
|
|
'd MMM yyyy, HH:mm',
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{detail?.content && (
|
|
<p className="overflow-y-auto text-sm whitespace-pre-wrap text-foreground">
|
|
{detail.content}
|
|
</p>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|