dstpabuaran.com/resources/js/components/notifications/notification-bell.tsx
Yoga Pangestu 166419134f Refactor component imports for consistency and organization
- Updated import paths for various components to align with new directory structure.
- Changed imports from 'row-actions', 'confirm-dialog', 'image-preview-button', and 'file-upload' to their respective new locations in 'data-display', 'dialogs', and 'inputs'.
- Adjusted imports in multiple pages including purchase, restock, transaction, category, customer, product, raw-material, supplier, roles, and settings.
- Ensured all relevant components are imported from their new locations to maintain functionality.
2026-08-07 13:48:46 +07:00

265 lines
10 KiB
TypeScript

import { router } from '@inertiajs/react';
import { Bell, Check, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Notification {
id: number;
title: string;
body: string | null;
url: string | null;
is_read: boolean;
created_at: string;
}
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const fetchUnreadCount = useCallback(async () => {
try {
const response = await fetch('/api/notifications/unread-count', {
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (response.ok && mountedRef.current) {
const data = await response.json();
setUnreadCount(data.count);
}
} catch {
// Silently fail
}
}, []);
const fetchNotifications = useCallback(async () => {
try {
const response = await fetch('/api/notifications', {
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (response.ok && mountedRef.current) {
const data = await response.json();
setNotifications(data);
}
} catch {
// Silently fail
}
}, []);
const markAsRead = useCallback(async (id: number) => {
try {
await fetch(`/api/notifications/${id}/read`, {
method: 'PATCH',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n)),
);
setUnreadCount((prev) => Math.max(0, prev - 1));
} catch {
// Silently fail
}
}, []);
const deleteNotification = useCallback(async (id: number) => {
try {
await fetch(`/api/notifications/${id}`, {
method: 'DELETE',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
setNotifications((prev) => prev.filter((n) => n.id !== id));
setUnreadCount((prev) => Math.max(0, prev - 1));
} catch {
// Silently fail
}
}, []);
const markAllAsRead = useCallback(async () => {
try {
await fetch('/api/notifications/read-all', {
method: 'PATCH',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
setNotifications((prev) =>
prev.map((n) => ({ ...n, is_read: true })),
);
setUnreadCount(0);
} catch {
// Silently fail
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial data fetch is safe
void fetchUnreadCount();
intervalRef.current = setInterval(() => {
void fetchUnreadCount();
}, 30000);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [fetchUnreadCount]);
useEffect(() => {
if (isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- fetch on dropdown open is safe
void fetchNotifications();
}
}, [isOpen, fetchNotifications]);
return (
<DropdownMenu onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative h-9 w-9"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs"
>
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
)}
<span className="sr-only">Notifikasi</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[350px] w-80">
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-sm font-semibold">Notifikasi</span>
{unreadCount > 0 && (
<button
onClick={() => {
void markAllAsRead();
}}
className="text-xs text-muted-foreground hover:text-foreground"
>
Tandai semua dibaca
</button>
)}
</div>
{notifications.length === 0 ? (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
Tidak ada notifikasi
</div>
) : (
notifications.map((notification) => (
<div key={notification.id}>
<div
className={`flex items-start gap-2 px-4 py-3 ${
!notification.is_read
? 'border-l-2 border-l-primary bg-primary/5'
: 'opacity-60'
}`}
>
<div
className="min-w-0 flex-1 cursor-pointer"
onClick={() => {
if (notification.url) {
router.visit(notification.url);
}
}}
>
<span
className={`text-sm ${
!notification.is_read
? 'font-semibold'
: 'font-medium'
}`}
>
{notification.title}
</span>
{notification.body && (
<span className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground">
{notification.body}
</span>
)}
<span className="mt-0.5 block text-xs text-muted-foreground">
{new Date(
notification.created_at,
).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<div className="flex shrink-0 flex-col gap-1 pt-0.5">
{!notification.is_read && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="Tandai sudah dibaca"
onClick={() => {
void markAsRead(
notification.id,
);
}}
>
<Check className="h-3 w-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
title="Hapus"
onClick={() => {
void deleteNotification(
notification.id,
);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<DropdownMenuSeparator />
</div>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
}